fix(api-v3): address CodeRabbit findings from the blueprint-split review

Fixes to the api_v3 package split (PR #553), one per finding verified
against the actual code:

- __init__.py: _redact_credentials only blanked scalar values under a
  credential-named key; a bare list of secrets under such a key (e.g.
  tokens: ["a", "b"]) passed through untouched, since the list branch
  recursed with no memory that its key looked like a credential. Nested
  dicts still walk normally (a documented, tested behaviour -- a container
  like secrets: {api_key: ..., note: ...} is a section name, not a value to
  blank outright), but any value reached under a credential-shaped key is
  now actually blanked.

- __init__.py: the OAuth helper script's raw stderr/stdout went to
  logger.error unredacted (CWE-532) right next to a comment claiming this
  was deliberate; the HTTP response already used the existing redact_text
  helper. Routed the log line through the same helper.

- __init__.py / starlark.py: the standalone Starlark manifest fallback
  (used when the plugin instance isn't loaded) read-modified-wrote
  manifest.json with no lock, unlike StarlarkAppsPlugin._update_manifest_safe
  (plugin-repos/starlark-apps/manager.py), which already holds an flock for
  the same file when the plugin is loaded. Added _starlark_manifest_lock,
  mirroring that pattern, and wrapped every standalone read-modify-write
  call site in it. The app-config update route also wrote config.json and
  the manifest as two separate, non-transactional writes (a second,
  distinct finding at the same call site); config.json is now rolled back
  if the manifest write that follows it fails.

- backup.py: restore options used bare bool() on values from the request,
  so {"restore_secrets": "false"} restored secrets anyway (bool("false") is
  True). Switched to the existing _coerce_to_bool helper already used for
  this exact purpose elsewhere in the package.

- config.py: an automated import-rewrite mangled four user-facing
  validation strings and their neighbouring comments -- "Invalid start
  time" had become "Invalid start _pkg.time" (and likewise for "end time")
  in both the schedule and dim-schedule per-day validation paths.

- display.py: `import _pkg.time as time_module` -- _pkg is a local alias
  for the package, not a real importable module, so this raised
  ModuleNotFoundError whenever a caller restarted an already-running
  display service via /display/on-demand/start, after the on-demand
  request was already written to cache. Fixed to `import time`. Audited
  the rest of the package for the same `_pkg.<module>` import mistake;
  every other `_pkg.` reference is a legitimate attribute read-through
  (`_pkg.time.time()`, `_pkg._get_starlark_plugin()`, ...), not a broken
  import statement.

- fonts.py: validate_file_upload's max_size_mb parameter is silently
  unused by that helper (it only checks filename/extension) -- the font
  upload route saved arbitrarily large files as a result. Added the same
  seek-and-check pattern already used for the sibling .star upload.

- wifi.py: two ad hoc, inconsistent bool coercions. POST
  /wifi/ap/auto-enable used bare bool(), so a JSON string "false" enabled
  it. POST /wifi/radio's enabled/force parsing recognized real bool and
  some strings but not int 1/0 (1 is True is False in Python). Factored one
  small _parse_bool_ish helper local to this file and used it at all three
  sites.

Not changed: the "unknown/misspelled restore option keys default to True"
half of the backup.py finding -- the file's own comment documents that a
missing key deliberately means "restore everything," matching the
already-existing JSON-parse-failure guard a few lines above it; only the
bool-coercion defect was a real bug.

Added or extended regression tests for every fix, following each area's
existing test conventions. Full suite: 4328 passed, 62 skipped, 2 failed
on both this branch and origin/main (missing tzdata package breaks two
timezone-alias tests in test_onboarding_checklist.py, unrelated to this
change) -- no new failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3bPMESe2TfrGvbs1ef9c5
This commit is contained in:
Claude
2026-09-11 02:40:46 +00:00
parent 60d5d19898
commit 4e91cf1fef
15 changed files with 700 additions and 109 deletions
+37 -8
View File
@@ -8,6 +8,36 @@ from web_interface.blueprints.api_v3 import (
)
def _parse_bool_ish(value, default=False):
"""Coerce a JSON value that is supposed to be a boolean.
A JSON boolean arrives as a real Python bool, but these routes are a
public HTTP contract and not every caller sends one. `bool(value)` gets
two common cases wrong: `bool("false")` is True (a non-empty string is
always truthy), and a plain int does not match an `is True` check
(`1 is True` is False, since `True` is a distinct singleton from the int
`1`) -- so a caller sending `{"enabled": 1}` was silently treated as
False. Recognizes a real bool, "true"/"false"/"1"/"0"/"yes"/"no"
case-insensitively, and int 1/0; anything else falls back to `default`.
"""
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ('true', '1', 'yes'):
return True
if lowered in ('false', '0', 'no'):
return False
return default
if isinstance(value, int):
if value == 1:
return True
if value == 0:
return False
return default
return default
# WiFi Management Endpoints
@api_v3.route('/wifi/status', methods=['GET'])
def get_wifi_status():
@@ -273,7 +303,7 @@ def set_auto_enable_ap_mode():
'message': 'auto_enable_ap_mode is required'
}), 400
auto_enable = bool(data['auto_enable_ap_mode'])
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'], default=False)
wifi_manager = WiFiManager()
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
@@ -330,13 +360,12 @@ def set_wifi_radio():
'message': 'enabled is required'
}), 400
# Parse defensively: bool("false") is True, so mirror the string-aware
# coercion used for `force` — the endpoint is a public contract, not just
# the shipped UI (which always sends real JSON booleans).
_enabled_raw = data['enabled']
enabled = _enabled_raw is True or (isinstance(_enabled_raw, str) and _enabled_raw.lower() in ('true', '1', 'yes'))
_force_raw = data.get('force', False)
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1', 'yes'))
# Parse defensively: bool("false") is True and a plain int never
# matches `is True`, so `_parse_bool_ish` handles bool, string and
# int 1/0 — the endpoint is a public contract, not just the shipped
# UI (which always sends real JSON booleans).
enabled = _parse_bool_ish(data['enabled'], default=False)
force = _parse_bool_ish(data.get('force', False), default=False)
wifi_manager = WiFiManager()
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)