mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
refactor(api-v3): split the 10,469-line blueprint into a package (#553)
* refactor(api-v3): split the 10,469-line blueprint into a package web_interface/blueprints/api_v3.py held 111 routes, 56 helpers and 181 functions in one module -- 9% of the core by line count and three times the next largest file. It becomes a package of nine route modules grouped by path segment, plus __init__.py for the shared imports, constants, Blueprint and helpers. Every route module decorates the SAME api_v3 Blueprint object, so endpoint names stay api_v3.<function>, the URL map is unchanged and app.py is untouched. Verified: 111 routes before, 111 after, byte-identical rules, endpoints and methods, and every endpoint still on the one blueprint. plugins 3,867 config 1,178 starlark 692 system 619 fonts 452 misc 398 wifi 361 display 326 backup 212 __init__ 1,787 (imports, constants, Blueprint, 56 helpers) Two things the URL-map check could not catch, both found by running the suite: 1. PROJECT_ROOT = Path(__file__).parent.parent.parent. Moving the code one directory deeper made that resolve to web_interface/ instead of the project root. Nothing failed at import; it surfaced as ~110 tests failing with 404s and "installation script not found", because every path built from it was one level too shallow. Now parents[3], and test_api_v3_url_map.py asserts PROJECT_ROOT/run.py exists so the next move cannot repeat it. 2. Module-attribute patching. Tests do monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", ...) and a route module that binds such a name by value never sees the patch. The shared code therefore stays in __init__.py rather than moving to a _common submodule -- it has to live on the module the tests patch -- and the eleven names tests patch are read back through the package (_pkg.X) instead of bound by value. Those eleven were found by AST-scanning every setattr in the test tree, not by guessing; "time" is among them, used to drive a fake clock through the second-resolution credential-backup filenames. Test changes are confined to what genuinely moved: patch targets that now name the owning route module, imports of helpers, and six tests that scan the api_v3 source as a file and now read the package directory. Full suite: 4,278 passed, 68 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 * 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 * fix(api-v3): reject unknown restore option keys CodeRabbit's review of the blueprint split (#553) asked that POST /backup/restore reject option keys outside RestoreOptions' known set. The follow-up commit fixed the bool("false")-is-True bug with _coerce_to_bool but never added the key check: a typo'd or renamed key (e.g. "restoreSecrets") is silently ignored by opts_dict.get(key, True), so the flag stays at its True default and secrets get restored despite the caller's request saying otherwise -- with no indication anything was wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb * fix(api-v3): address CodeRabbit findings on the blueprint split - _redact_credentials: blank scalar descendants of objects reached through a credential-owned list (e.g. tokens: [{"value": "secret"}]) regardless of field name -- the existing name-based walk only protected direct dict values under a credential key, not list items. - wifi.py: reject enabled/force/auto_enable_ap_mode values _parse_bool_ish can't recognize (400) instead of silently treating them as False, which could disable Wi-Fi or the radio itself. - Starlark manifest locking: lock a stable manifest.json.lock sidecar instead of manifest.json itself, in both the standalone route path (_starlark_manifest_lock) and the plugin path (StarlarkAppsPlugin._save_manifest / _update_manifest_safe). manifest.json is replaced by an atomic rename on every write, which swaps in a fresh inode; a lock held on the old inode does not exclude a second locker that opens the path afresh right after the rename and gets the new inode, so two writers could race despite each holding "a lock". A sidecar that no write ever touches always resolves to the same inode for every locker. Skipped as stale: the "serialize the complete manifest read-modify-write" finding at api_v3/__init__.py -- every standalone handler that calls _write_starlark_manifest is already wrapped in _starlark_manifest_lock() on this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(api-v3): re-check reconciliation findings by the reconciler's own rules Both CodeRabbit findings on the merge commit, verified against the code first. Major, plugins.py: the stale-findings filter derived its own notion of "in config" and "on disk", and both were looser than the reconciliation module's. set(load_config()) also contains system keys, the secrets-file keys load_config() merges in, and non-dict values; and any directory holding a manifest.json counted as installed even when that manifest does not parse. Either looseness clears a finding that is still true -- and a secrets key read as a plugin is the precise bug the filter exists to stop reporting, so reintroducing that asymmetry while re-checking was the wrong way round. The two extractions now live in state_reconciliation.py as config_plugin_ids() and disk_plugin_ids(), with ignored_config_keys() and secrets_top_level_keys() alongside. _get_config_state() and _get_disk_state() use them too, so there is one definition rather than two that can drift. _get_disk_state() re-reads each manifest for version/name after taking membership from the shared extractor; that costs one extra small read per plugin on a path that runs once per boot. Minor, the new test: the fixture assigned api_v3.config_manager and api_v3.plugin_manager directly. Those live on a module-level blueprint singleton, so the mocks leaked into every later test that imports api_v3 -- pointing at a tmp_path already deleted. Both now go through monkeypatch.setattr, which restores them. This is the same pollution class that made an earlier test in this session break seven unrelated ones, so it is worth getting right. Five cases added for the parity itself: a secrets key, a system key and a non-dict value must not clear an "installed but missing from config" finding, and neither an unparseable manifest nor a .standalone-backup- directory may count as installed. All five fail against the looser version. Linux CI on the preceding commit: Core unit tests, plugin harness, CodeQL and CodeRabbit all pass. Codacy reads action_required on every commit of this branch including the first, so it is pre-existing and not from this work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vendored
+947
@@ -0,0 +1,947 @@
|
||||
[
|
||||
[
|
||||
"/api/v3/backup/<path:filename>",
|
||||
"api_v3.backup_delete",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/backup/download/<path:filename>",
|
||||
"api_v3.backup_download",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/backup/export",
|
||||
"api_v3.backup_export",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/backup/list",
|
||||
"api_v3.backup_list",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/backup/preview",
|
||||
"api_v3.backup_preview",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/backup/restore",
|
||||
"api_v3.backup_restore",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/backup/validate",
|
||||
"api_v3.backup_validate",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/cache/delete",
|
||||
"api_v3.delete_cache_file",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/cache/list",
|
||||
"api_v3.list_cache_files",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/dim-schedule",
|
||||
"api_v3.get_dim_schedule_config",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/dim-schedule",
|
||||
"api_v3.save_dim_schedule_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/main",
|
||||
"api_v3.get_main_config",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/main",
|
||||
"api_v3.save_main_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/raw/main",
|
||||
"api_v3.save_raw_main_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/raw/secrets",
|
||||
"api_v3.save_raw_secrets_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/schedule",
|
||||
"api_v3.get_schedule_config",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/schedule",
|
||||
"api_v3.save_schedule_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/config/secrets",
|
||||
"api_v3.get_secrets_config",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/display/current",
|
||||
"api_v3.get_display_current",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/display/current-status",
|
||||
"api_v3.get_current_display_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/display/modes",
|
||||
"api_v3.get_display_modes",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/display/on-demand/start",
|
||||
"api_v3.start_on_demand_display",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/display/on-demand/status",
|
||||
"api_v3.get_on_demand_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/display/on-demand/stop",
|
||||
"api_v3.stop_on_demand_display",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/errors/clear",
|
||||
"api_v3.clear_old_errors",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/errors/plugin/<plugin_id>",
|
||||
"api_v3.get_plugin_errors",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/errors/summary",
|
||||
"api_v3.get_error_summary",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/<font_family>",
|
||||
"api_v3.delete_font",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/catalog",
|
||||
"api_v3.get_fonts_catalog",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/overrides",
|
||||
"api_v3.get_fonts_overrides",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/overrides",
|
||||
"api_v3.save_fonts_overrides",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/overrides/<element_key>",
|
||||
"api_v3.delete_font_override",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/preview",
|
||||
"api_v3.get_font_preview",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/tokens",
|
||||
"api_v3.get_font_tokens",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/upload",
|
||||
"api_v3.upload_font",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/hardware/status",
|
||||
"api_v3.get_hardware_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/health",
|
||||
"api_v3.get_health",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/logs",
|
||||
"api_v3.get_logs",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/<plugin_id>/static/<path:file_path>",
|
||||
"api_v3.serve_plugin_static",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/action",
|
||||
"api_v3.execute_plugin_action",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/assets/delete",
|
||||
"api_v3.delete_plugin_asset",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/assets/list",
|
||||
"api_v3.list_plugin_assets",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/assets/upload",
|
||||
"api_v3.upload_plugin_asset",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/authenticate/spotify",
|
||||
"api_v3.authenticate_spotify",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/authenticate/ytm",
|
||||
"api_v3.authenticate_ytm",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/calendar/authenticate",
|
||||
"api_v3.authenticate_calendar",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/calendar/list-calendars",
|
||||
"api_v3.list_calendar_calendars",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/calendar/upload-credentials",
|
||||
"api_v3.upload_calendar_credentials",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/config",
|
||||
"api_v3.get_plugin_config",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/config",
|
||||
"api_v3.save_plugin_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/config/reset",
|
||||
"api_v3.reset_plugin_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/health",
|
||||
"api_v3.get_plugin_health",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/health/<plugin_id>",
|
||||
"api_v3.get_plugin_health_single",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/health/<plugin_id>/reset",
|
||||
"api_v3.reset_plugin_health",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/install",
|
||||
"api_v3.install_plugin",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/install-from-url",
|
||||
"api_v3.install_plugin_from_url",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/installed",
|
||||
"api_v3.get_installed_plugins",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/limits/<plugin_id>",
|
||||
"api_v3.manage_plugin_limits",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/metrics",
|
||||
"api_v3.get_plugin_metrics",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/metrics/<plugin_id>",
|
||||
"api_v3.get_plugin_metrics_single",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/metrics/<plugin_id>/reset",
|
||||
"api_v3.reset_plugin_metrics",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/of-the-day/json/delete",
|
||||
"api_v3.delete_of_the_day_json",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/of-the-day/json/upload",
|
||||
"api_v3.upload_of_the_day_json",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/operation/<operation_id>",
|
||||
"api_v3.get_operation_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/operation/history",
|
||||
"api_v3.clear_operation_history",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/operation/history",
|
||||
"api_v3.get_operation_history",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/reconciliation-status",
|
||||
"api_v3.get_reconciliation_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/registry-from-url",
|
||||
"api_v3.get_registry_from_url",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/saved-repositories",
|
||||
"api_v3.add_saved_repository",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/saved-repositories",
|
||||
"api_v3.get_saved_repositories",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/saved-repositories",
|
||||
"api_v3.remove_saved_repository",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/schema",
|
||||
"api_v3.get_plugin_schema",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/state",
|
||||
"api_v3.get_plugin_state",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/state/reconcile",
|
||||
"api_v3.reconcile_plugin_state",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/store/github-status",
|
||||
"api_v3.get_github_auth_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/store/list",
|
||||
"api_v3.list_plugin_store",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/store/refresh",
|
||||
"api_v3.refresh_plugin_store",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/toggle",
|
||||
"api_v3.toggle_plugin",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/uninstall",
|
||||
"api_v3.uninstall_plugin",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/plugins/update",
|
||||
"api_v3.update_plugin",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/skins",
|
||||
"api_v3.list_skins",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps",
|
||||
"api_v3.get_starlark_apps",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps/<app_id>",
|
||||
"api_v3.get_starlark_app",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps/<app_id>",
|
||||
"api_v3.uninstall_starlark_app",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps/<app_id>/config",
|
||||
"api_v3.get_starlark_app_config",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps/<app_id>/config",
|
||||
"api_v3.update_starlark_app_config",
|
||||
[
|
||||
"OPTIONS",
|
||||
"PUT"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps/<app_id>/render",
|
||||
"api_v3.render_starlark_app",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/apps/<app_id>/toggle",
|
||||
"api_v3.toggle_starlark_app",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/install-pixlet",
|
||||
"api_v3.install_pixlet",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/repository/browse",
|
||||
"api_v3.browse_tronbyte_repository",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/repository/categories",
|
||||
"api_v3.get_tronbyte_categories",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/repository/install",
|
||||
"api_v3.install_from_tronbyte_repository",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/status",
|
||||
"api_v3.get_starlark_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/starlark/upload",
|
||||
"api_v3.upload_starlark_app",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/sync/status",
|
||||
"api_v3.get_sync_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/action",
|
||||
"api_v3.execute_system_action",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/check-update",
|
||||
"api_v3.check_for_update",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/git-branches",
|
||||
"api_v3.get_git_branches",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/git-info",
|
||||
"api_v3.get_git_info",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/status",
|
||||
"api_v3.get_system_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/system/version",
|
||||
"api_v3.get_system_version",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/ap/auto-enable",
|
||||
"api_v3.get_auto_enable_ap_mode",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/ap/auto-enable",
|
||||
"api_v3.set_auto_enable_ap_mode",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/ap/disable",
|
||||
"api_v3.disable_ap_mode",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/ap/enable",
|
||||
"api_v3.enable_ap_mode",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/connect",
|
||||
"api_v3.connect_wifi",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/disconnect",
|
||||
"api_v3.disconnect_wifi",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/radio",
|
||||
"api_v3.get_wifi_radio",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/radio",
|
||||
"api_v3.set_wifi_radio",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/scan",
|
||||
"api_v3.scan_wifi_networks",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/wifi/status",
|
||||
"api_v3.get_wifi_status",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Regression test: POST /fonts/upload must enforce its own stated size limit.
|
||||
|
||||
validate_file_upload(filename, max_size_mb=10, allowed_extensions=[...]) reads
|
||||
like it checks the upload's size, but it only ever validated the filename
|
||||
(traversal characters, extension) -- max_size_mb was accepted and silently
|
||||
ignored. Nothing else in the handler checked the actual upload size either,
|
||||
so it saved whatever was posted to assets/fonts/<family><ext> regardless of
|
||||
size, unlike the sibling .star and plugin-asset upload routes, which check
|
||||
`file.tell()` against a stated limit before saving.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
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
|
||||
|
||||
URL = "/api/v3/fonts/upload"
|
||||
TEN_MB = 10 * 1024 * 1024
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fonts_root(tmp_path):
|
||||
# PROJECT_ROOT is bound by value in fonts.py, so it is patched there.
|
||||
with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path):
|
||||
yield tmp_path
|
||||
|
||||
|
||||
def upload(client, content, filename="myfont.ttf", family="myfont"):
|
||||
data = {
|
||||
"font_file": (io.BytesIO(content), filename),
|
||||
"font_family": family,
|
||||
}
|
||||
return client.post(URL, data=data, content_type="multipart/form-data")
|
||||
|
||||
|
||||
class TestFontUploadSizeLimit:
|
||||
def test_oversized_font_is_rejected(self, api_v3_client, fonts_root):
|
||||
response = upload(api_v3_client, b"x" * (TEN_MB + 1))
|
||||
assert response.status_code == 400
|
||||
assert "too large" in response.get_json()["message"].lower()
|
||||
assert not (fonts_root / "assets" / "fonts" / "myfont.ttf").exists(), (
|
||||
"an oversized font was saved to disk before being rejected")
|
||||
|
||||
def test_a_font_right_at_the_limit_is_accepted(self, api_v3_client, fonts_root):
|
||||
response = upload(api_v3_client, b"x" * TEN_MB,
|
||||
filename="atlimit.ttf", family="atlimit")
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert (fonts_root / "assets" / "fonts" / "atlimit.ttf").exists()
|
||||
|
||||
def test_an_ordinary_small_font_is_still_accepted(self, api_v3_client, fonts_root):
|
||||
response = upload(api_v3_client, b"fake font bytes",
|
||||
filename="small.ttf", family="small")
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert (fonts_root / "assets" / "fonts" / "small.ttf").read_bytes() == b"fake font bytes"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Regression test: POST /display/on-demand/start restarting a running
|
||||
service must not import a name that does not exist.
|
||||
|
||||
display.py has `import web_interface.blueprints.api_v3 as _pkg` and reads
|
||||
mutable, test-patched attributes back through it (`_pkg.time.time()`,
|
||||
`_pkg._get_starlark_plugin()`, ...) rather than binding them by value, per
|
||||
the package's own docstring. One spot went further and wrote a genuine
|
||||
`import` *statement* against that alias --
|
||||
|
||||
import _pkg.time as time_module
|
||||
|
||||
-- but `_pkg` is a local name bound by `import ... as _pkg` in this module,
|
||||
not a real top-level package, so `import _pkg.time` is not something Python
|
||||
can resolve; it raises ModuleNotFoundError. That line only runs when the
|
||||
display service is already running and the caller also asked to (re)start
|
||||
it, so this endpoint failed on exactly the restart path -- the one where a
|
||||
cache write recording the new on-demand request had already happened.
|
||||
|
||||
The route wraps its body in `except Exception`, so the failure reached the
|
||||
caller as a handled 500 with a generic message, not an unhandled crash --
|
||||
but a 500 all the same on a request that should have restarted the service
|
||||
and reported success.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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
|
||||
|
||||
URL = "/api/v3/display/on-demand/start"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def restart_path(api_v3_module):
|
||||
"""Force the `service_was_running and start_service` branch.
|
||||
|
||||
plugin_manager and config_manager are set to None so the route takes
|
||||
the simplest path to that branch rather than tripping over unrelated
|
||||
MagicMock plumbing; _ensure_cache_manager, _get_display_service_status,
|
||||
_stop_display_service and _ensure_display_service_running are bound by
|
||||
value in display.py (see its own docstring), so they are patched on
|
||||
that submodule rather than on the package.
|
||||
"""
|
||||
api_v3_module.api_v3.plugin_manager = None
|
||||
api_v3_module.api_v3.config_manager = None
|
||||
|
||||
with patch("web_interface.blueprints.api_v3.display._ensure_cache_manager") as ensure_cache, \
|
||||
patch("web_interface.blueprints.api_v3.display._get_display_service_status") as get_status, \
|
||||
patch("web_interface.blueprints.api_v3.display._stop_display_service") as stop_service, \
|
||||
patch("web_interface.blueprints.api_v3.display._ensure_display_service_running") as ensure_running:
|
||||
ensure_cache.return_value = MagicMock()
|
||||
# Active before the request: service_was_running becomes True.
|
||||
get_status.return_value = {"active": True}
|
||||
ensure_running.return_value = {"active": True}
|
||||
yield {
|
||||
"ensure_cache": ensure_cache,
|
||||
"get_status": get_status,
|
||||
"stop_service": stop_service,
|
||||
"ensure_running": ensure_running,
|
||||
}
|
||||
|
||||
|
||||
class TestRestartingARunningService:
|
||||
def test_it_does_not_500(self, api_v3_client, restart_path):
|
||||
response = api_v3_client.post(
|
||||
URL, json={"plugin_id": "weather", "start_service": True})
|
||||
body = response.get_json()
|
||||
assert response.status_code == 200, body
|
||||
assert body["status"] == "success", body
|
||||
|
||||
def test_the_service_is_actually_stopped_and_restarted(
|
||||
self, api_v3_client, restart_path):
|
||||
api_v3_client.post(
|
||||
URL, json={"plugin_id": "weather", "start_service": True})
|
||||
restart_path["stop_service"].assert_called_once()
|
||||
restart_path["ensure_running"].assert_called_once()
|
||||
|
||||
def test_a_service_that_was_not_running_is_not_stopped_first(
|
||||
self, api_v3_client, restart_path):
|
||||
# The buggy import sits inside `if service_was_running and
|
||||
# start_service`, so it only ever fired on the restart path --
|
||||
# this is the other side of that branch, unaffected either way,
|
||||
# kept here so the branch condition itself stays covered.
|
||||
restart_path["get_status"].return_value = {"active": False}
|
||||
response = api_v3_client.post(
|
||||
URL, json={"plugin_id": "weather", "start_service": True})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
restart_path["stop_service"].assert_not_called()
|
||||
@@ -106,7 +106,12 @@ class TestMissingBodyGivesTheDeclaredError:
|
||||
|
||||
|
||||
class TestNoBodyReadContradictsItsOwnGuard:
|
||||
SOURCE = Path(__file__).parent.parent / "web_interface/blueprints/api_v3.py"
|
||||
PKG = Path(__file__).parent.parent / "web_interface/blueprints/api_v3"
|
||||
|
||||
@property
|
||||
def _source(self) -> str:
|
||||
"""api_v3 is a package; read every module of it."""
|
||||
return "\n".join(p.read_text() for p in sorted(self.PKG.glob("*.py")))
|
||||
|
||||
def test_no_or_default_read_is_unguarded(self):
|
||||
"""`get_json() or <default>` is a contradiction without silent=True.
|
||||
@@ -115,7 +120,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
||||
means the call raises before the default can apply.
|
||||
"""
|
||||
offenders = [
|
||||
line.strip() for line in self.SOURCE.read_text().splitlines()
|
||||
line.strip() for line in self._source.splitlines()
|
||||
if "request.get_json()" in line and " or " in line
|
||||
]
|
||||
assert offenders == [], (
|
||||
@@ -124,7 +129,7 @@ class TestNoBodyReadContradictsItsOwnGuard:
|
||||
|
||||
def test_no_not_data_guard_is_unreachable(self):
|
||||
"""A `if not data:` guard needs a read that can actually return None."""
|
||||
lines = self.SOURCE.read_text().splitlines()
|
||||
lines = self._source.splitlines()
|
||||
offenders = []
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r"=\s*request\.get_json\(\)\s*$", line):
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Regression test: per-day schedule validation errors must say "time", not
|
||||
"_pkg.time".
|
||||
|
||||
The split into a package rewrote every bare `time` reference that needed to
|
||||
read through the shared module as `_pkg.time` (see the package's own
|
||||
docstring on why -- tests patch it, so it has to be read back live rather
|
||||
than bound by value). That rewrite was mechanical and matched the substring
|
||||
"time" inside string literals and comments too, so the user-facing message
|
||||
|
||||
"Invalid start time for {day}: ..."
|
||||
|
||||
came out as
|
||||
|
||||
"Invalid start _pkg.time for {day}: ..."
|
||||
|
||||
in both POST /config/schedule and POST /config/dim-schedule, for both the
|
||||
start and end time of a per-day entry.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", [
|
||||
"/api/v3/config/schedule",
|
||||
"/api/v3/config/dim-schedule",
|
||||
])
|
||||
class TestPerDayTimeErrorsAreNotCorrupted:
|
||||
def test_invalid_start_time_message(self, api_v3_client, api_v3_module, url):
|
||||
response = api_v3_client.post(url, json={
|
||||
"mode": "per_day",
|
||||
"monday_start": "not-a-time",
|
||||
})
|
||||
assert response.status_code == 400
|
||||
message = response.get_json()["message"]
|
||||
assert "_pkg" not in message, message
|
||||
assert message.startswith("Invalid start time for monday:"), message
|
||||
|
||||
def test_invalid_end_time_message(self, api_v3_client, api_v3_module, url):
|
||||
response = api_v3_client.post(url, json={
|
||||
"mode": "per_day",
|
||||
"monday_start": "07:00",
|
||||
"monday_end": "not-a-time",
|
||||
})
|
||||
assert response.status_code == 400
|
||||
message = response.get_json()["message"]
|
||||
assert "_pkg" not in message, message
|
||||
assert message.startswith("Invalid end time for monday:"), message
|
||||
@@ -0,0 +1,136 @@
|
||||
"""The /api/v3 URL map is a contract, and the split must not have moved it.
|
||||
|
||||
api_v3.py was one 10,469-line module and is now a package. Every route module
|
||||
in it decorates the *same* Blueprint object, so this refactor was supposed to
|
||||
be invisible from outside: same URLs, same endpoint names, same methods.
|
||||
|
||||
"Supposed to be" is the problem. A route function silently dropped during a
|
||||
move -- a module that never gets imported, a decorator left behind -- costs
|
||||
nothing at import time and fails only when someone hits the URL. So the map is
|
||||
pinned here.
|
||||
|
||||
The snapshot is intentionally the *whole* map rather than a count. A count
|
||||
passes when one route is deleted and another added, which is exactly the shape
|
||||
a careless move produces.
|
||||
|
||||
If you are adding a route, this test is meant to fail: add the entry to
|
||||
EXPECTED. If you are moving one between modules, it is meant to pass unchanged
|
||||
-- endpoint names are `api_v3.<function>` regardless of which module the
|
||||
function lives in, and that is the property that makes the package safe.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
|
||||
SNAPSHOT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"fixtures", "api_v3_url_map.json")
|
||||
|
||||
|
||||
def _current_map():
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
return sorted(
|
||||
[r.rule, r.endpoint, sorted(r.methods)]
|
||||
for r in app.url_map.iter_rules()
|
||||
if r.endpoint != "static"
|
||||
)
|
||||
|
||||
|
||||
def test_the_url_map_matches_the_snapshot():
|
||||
current = _current_map()
|
||||
with open(SNAPSHOT, encoding="utf-8") as fh:
|
||||
expected = json.load(fh)
|
||||
|
||||
cur = {(r, e) for r, e, _ in current}
|
||||
exp = {(r, e) for r, e, _ in expected}
|
||||
|
||||
lost = sorted(exp - cur)
|
||||
added = sorted(cur - exp)
|
||||
assert not lost, (
|
||||
f"{len(lost)} route(s) disappeared from /api/v3: {lost[:5]}. "
|
||||
"A route lost in a module move costs nothing at import time and fails "
|
||||
"only when someone hits the URL.")
|
||||
assert not added, (
|
||||
f"{len(added)} new route(s): {added[:5]}. If that is intended, "
|
||||
f"regenerate {os.path.relpath(SNAPSHOT)}.")
|
||||
|
||||
# Methods too: a route that quietly loses POST is still a broken route.
|
||||
cur_methods = {(r, e): m for r, e, m in current}
|
||||
for rule, endpoint, methods in expected:
|
||||
assert cur_methods[(rule, endpoint)] == methods, (
|
||||
f"{rule} ({endpoint}) methods changed: "
|
||||
f"{methods} -> {cur_methods[(rule, endpoint)]}")
|
||||
|
||||
|
||||
def test_every_endpoint_is_on_the_one_blueprint():
|
||||
"""The package must not fragment into several blueprints.
|
||||
|
||||
Splitting into per-domain *blueprints* would rename every endpoint from
|
||||
`api_v3.foo` to `api_v3_plugins.foo` and break any url_for() that names
|
||||
one. Keeping a single Blueprint object across the modules is what makes
|
||||
the split a pure code move, so assert it rather than trusting it.
|
||||
"""
|
||||
for rule, endpoint, _ in _current_map():
|
||||
assert endpoint.startswith("api_v3."), (
|
||||
f"{rule} is registered as {endpoint}, not on the api_v3 blueprint")
|
||||
|
||||
|
||||
def test_the_snapshot_is_not_empty():
|
||||
"""Guards the failure mode this file exists to prevent.
|
||||
|
||||
An empty or truncated snapshot would make every assertion above pass
|
||||
vacuously -- the same trap as a route map that imports no modules.
|
||||
"""
|
||||
with open(SNAPSHOT, encoding="utf-8") as fh:
|
||||
expected = json.load(fh)
|
||||
assert len(expected) > 100, (
|
||||
f"snapshot has only {len(expected)} routes; it should have the whole "
|
||||
"/api/v3 surface")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [
|
||||
"backup", "config", "display", "fonts", "misc",
|
||||
"plugins", "starlark", "system", "wifi",
|
||||
])
|
||||
def test_every_route_module_contributes(module):
|
||||
"""Each module must actually register something.
|
||||
|
||||
A module that fails to import, or that is left out of __init__, takes its
|
||||
routes with it silently -- the package still imports and the app still
|
||||
starts.
|
||||
"""
|
||||
import importlib
|
||||
mod = importlib.import_module(f"web_interface.blueprints.api_v3.{module}")
|
||||
routes = [n for n in dir(mod)
|
||||
if callable(getattr(mod, n, None))
|
||||
and getattr(getattr(mod, n), "__module__", "") == mod.__name__]
|
||||
assert routes, f"{module}.py defines no view functions"
|
||||
|
||||
|
||||
def test_project_root_points_at_the_project():
|
||||
"""PROJECT_ROOT is derived from __file__, so moving the file breaks it.
|
||||
|
||||
The split moved this code from web_interface/blueprints/api_v3.py to
|
||||
web_interface/blueprints/api_v3/_common.py -- one directory deeper -- and
|
||||
`Path(__file__).parent.parent.parent` quietly began resolving to
|
||||
web_interface/ instead of the project root. Nothing failed at import. It
|
||||
surfaced as routes returning 404 and "installation script not found",
|
||||
because every path built from it pointed one level too shallow.
|
||||
|
||||
A URL-map check cannot catch that: the routes were all registered, they
|
||||
just could not find anything.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from web_interface.blueprints.api_v3 import PROJECT_ROOT
|
||||
|
||||
# The project root is the directory holding run.py and web_interface/.
|
||||
assert (PROJECT_ROOT / "run.py").is_file(), (
|
||||
f"PROJECT_ROOT is {PROJECT_ROOT}, which has no run.py; it is not the "
|
||||
"project root")
|
||||
assert (PROJECT_ROOT / "web_interface").is_dir()
|
||||
assert PROJECT_ROOT == Path(__file__).resolve().parents[1]
|
||||
@@ -195,7 +195,7 @@ class TestRadio:
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(True, True), ("true", True), ("1", True), ("yes", True),
|
||||
(False, False), ("false", False), ("off", False), (0, False),
|
||||
(False, False), ("false", False), ("no", False), (0, False),
|
||||
])
|
||||
def test_enabled_coercion_is_string_aware(
|
||||
self, api_v3_client, wifi_manager, raw, expected):
|
||||
@@ -228,6 +228,84 @@ class TestRadio:
|
||||
wifi_manager.set_wifi_radio.side_effect = RuntimeError("boom")
|
||||
assert api_v3_client.post(self.URL, json={"enabled": True}).status_code == 500
|
||||
|
||||
def test_unrecognized_enabled_value_is_rejected_not_treated_as_false(
|
||||
self, api_v3_client, wifi_manager):
|
||||
# An invalid `enabled` used to silently fall back to False, which
|
||||
# can disconnect Wi-Fi (or, with force=true, drop the caller's own
|
||||
# connection to this interface) even though nothing asked for that.
|
||||
response = api_v3_client.post(self.URL, json={"enabled": "typo"})
|
||||
assert response.status_code == 400
|
||||
wifi_manager.set_wifi_radio.assert_not_called()
|
||||
|
||||
def test_unrecognized_force_value_is_rejected_not_treated_as_false(
|
||||
self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(
|
||||
self.URL, json={"enabled": False, "force": "typo"})
|
||||
assert response.status_code == 400
|
||||
wifi_manager.set_wifi_radio.assert_not_called()
|
||||
|
||||
|
||||
class TestAutoEnableApMode:
|
||||
URL = "/api/v3/wifi/ap/auto-enable"
|
||||
|
||||
def test_requires_the_field(self, api_v3_client, wifi_manager):
|
||||
response = api_v3_client.post(self.URL, json={})
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
(True, True), (False, False),
|
||||
("true", True), ("True", True), ("1", True), ("yes", True),
|
||||
("false", False), ("False", False), ("0", False), ("no", False),
|
||||
(1, True), (0, False),
|
||||
])
|
||||
def test_value_is_coerced_not_just_truthy(
|
||||
self, api_v3_client, wifi_manager, raw, expected):
|
||||
# Regression: bool(data['auto_enable_ap_mode']) meant a caller who
|
||||
# sent the JSON string "false" got it stored as True — bool("false")
|
||||
# is True, since any non-empty string is truthy.
|
||||
wifi_manager.config = {}
|
||||
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": raw})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["data"]["auto_enable_ap_mode"] is expected
|
||||
assert wifi_manager.config["auto_enable_ap_mode"] is expected
|
||||
|
||||
def test_a_string_false_does_not_enable_it(self, api_v3_client, wifi_manager):
|
||||
# The exact shape of the bug.
|
||||
wifi_manager.config = {}
|
||||
api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "false"})
|
||||
assert wifi_manager.config["auto_enable_ap_mode"] is False
|
||||
|
||||
def test_unrecognized_value_is_rejected_not_treated_as_false(
|
||||
self, api_v3_client, wifi_manager):
|
||||
wifi_manager.config = {}
|
||||
response = api_v3_client.post(self.URL, json={"auto_enable_ap_mode": "typo"})
|
||||
assert response.status_code == 400
|
||||
assert "auto_enable_ap_mode" not in wifi_manager.config
|
||||
|
||||
|
||||
class TestRadioEnabledAndForceAcceptIntegers:
|
||||
"""`{"enabled": 1}` / `{"enabled": 0}` used to be mishandled: the old
|
||||
coercion was `raw is True or (isinstance(raw, str) and ...)`, and
|
||||
`1 is True` is False in Python -- an int is never the `True` singleton
|
||||
even though it equals it -- so a plain integer fell through to False
|
||||
regardless of its value.
|
||||
"""
|
||||
URL = "/api/v3/wifi/radio"
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
||||
def test_enabled_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
||||
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||
api_v3_client.post(self.URL, json={"enabled": raw})
|
||||
wifi_manager.set_wifi_radio.assert_called_once_with(expected, force=False)
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [(1, True), (0, False)])
|
||||
def test_force_as_an_integer(self, api_v3_client, wifi_manager, raw, expected):
|
||||
wifi_manager.set_wifi_radio.return_value = (True, "ok", None)
|
||||
wifi_manager.get_wifi_radio_state.return_value = {}
|
||||
api_v3_client.post(self.URL, json={"enabled": True, "force": raw})
|
||||
wifi_manager.set_wifi_radio.assert_called_once_with(True, force=expected)
|
||||
|
||||
|
||||
class TestNoRealNetworking:
|
||||
def test_wifi_manager_is_never_constructed_for_real(self, api_v3_client):
|
||||
|
||||
@@ -103,6 +103,54 @@ def test_non_dict_input_passes_through():
|
||||
assert _redact_credentials(None) is None
|
||||
|
||||
|
||||
def test_a_list_of_bare_credentials_is_redacted():
|
||||
"""A credential-named key whose value is a list of scalars, not a list
|
||||
of named-field dicts, used to pass through untouched: the dict branch
|
||||
recursed into `v` on its own value only, so a bare string list item had
|
||||
no field name to test and fell through the base case unredacted.
|
||||
"""
|
||||
config = {"tokens": ["abc123", "def456"], "timezone": "America/New_York"}
|
||||
out = _redact_credentials(config)
|
||||
assert out["tokens"] == ["", ""]
|
||||
assert out["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
def test_a_list_nested_inside_a_credential_named_container_is_also_blanked():
|
||||
"""The list fix applies at any depth under a credential-named key, not
|
||||
only when the list is the key's direct value.
|
||||
"""
|
||||
config = {"auth": {"backup_tokens": ["a", "b"], "note": "keep"}}
|
||||
out = _redact_credentials(config)
|
||||
assert out["auth"]["backup_tokens"] == ["", ""]
|
||||
assert out["auth"]["note"] == "keep"
|
||||
|
||||
|
||||
def test_objects_inside_a_credential_named_list_are_blanked_regardless_of_field_names():
|
||||
"""A list item has no field name of its own to test against
|
||||
`_CREDENTIAL_NAME_PARTS`, so an object reached through a credential-owned
|
||||
list must be blanked outright rather than walked by field name -- unlike
|
||||
the dict-directly-under-a-credential-key case in the test above.
|
||||
"""
|
||||
config = {"tokens": [{"value": "secret", "kind": "bearer"}]}
|
||||
out = _redact_credentials(config)
|
||||
assert out["tokens"] == [{"value": "", "kind": ""}]
|
||||
|
||||
|
||||
def test_credential_shaped_container_with_named_fields_still_only_blanks_those():
|
||||
"""Unchanged behaviour for the case the container test above already
|
||||
covers: a dict whose sub-keys are semantically named fields is walked
|
||||
normally, not blanket-blanked, so an ordinary field next to a
|
||||
credential-named one survives.
|
||||
"""
|
||||
config = {"secrets": {"api_key": "k", "note": "keep", "list": ["a", "b"]}}
|
||||
out = _redact_credentials(config)
|
||||
assert out["secrets"]["api_key"] == ""
|
||||
assert out["secrets"]["note"] == "keep"
|
||||
# "list" isn't itself credential-named, so it is walked, not blanked --
|
||||
# but it holds no credential-named field either, so it survives whole.
|
||||
assert out["secrets"]["list"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_the_endpoint_itself_redacts():
|
||||
"""Through the view function, not the helper.
|
||||
|
||||
@@ -130,7 +178,8 @@ def test_the_endpoint_itself_redacts():
|
||||
app = flask.Flask(__name__)
|
||||
try:
|
||||
with app.test_request_context("/config/main"):
|
||||
response = mod.get_main_config()
|
||||
# get_main_config is a route; it lives in the config module now.
|
||||
response = mod.config.get_main_config()
|
||||
payload = response.get_json() if hasattr(response, "get_json") else _json.loads(response[0].data)
|
||||
finally:
|
||||
mod.api_v3.config_manager = previous
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""The reconciliation-status endpoint must re-check its stored verdict.
|
||||
|
||||
#557 added _drop_stale_reconciliation_findings() so a resolved condition stops
|
||||
being reported: the verdict is a snapshot written once per run, and a run that
|
||||
fails to apply a fix also declines to retry, so a device whose plugins were all
|
||||
present in config kept being told for hours that four of them were missing.
|
||||
|
||||
That wiring had no test. Only the pure still_unresolved() helper in
|
||||
src/plugin_system/ was covered, which lives outside web_interface and therefore
|
||||
survived the api_v3 blueprint split untouched -- so when #553 moved this
|
||||
endpoint into api_v3/plugins.py, losing the filter would have been completely
|
||||
silent. This test exists so that cannot happen again.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
IN_CONFIG = "plugin_missing_in_config"
|
||||
ON_DISK = "plugin_missing_on_disk"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""App whose status file, config and plugins dir are all under our control."""
|
||||
monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path))
|
||||
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
plugins_dir.mkdir()
|
||||
|
||||
def _install(plugin_id):
|
||||
d = plugins_dir / plugin_id
|
||||
d.mkdir()
|
||||
(d / "manifest.json").write_text(json.dumps({"id": plugin_id}), encoding="utf-8")
|
||||
|
||||
def _setup(verdict, config=None, installed=(), secrets=None, corrupt=()):
|
||||
(tmp_path / "ledmatrix_reconciliation.json").write_text(
|
||||
json.dumps(verdict), encoding="utf-8")
|
||||
for pid in installed:
|
||||
_install(pid)
|
||||
for pid in corrupt:
|
||||
d = plugins_dir / pid
|
||||
d.mkdir(exist_ok=True)
|
||||
(d / "manifest.json").write_text("{ this is not json", encoding="utf-8")
|
||||
|
||||
secrets_path = tmp_path / "config_secrets.json"
|
||||
secrets_path.write_text(json.dumps(secrets or {}), encoding="utf-8")
|
||||
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = dict(config or {})
|
||||
cm.get_secrets_path.return_value = str(secrets_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins_dir = str(plugins_dir)
|
||||
# setattr via monkeypatch: these live on a module-level blueprint
|
||||
# singleton, so assigning them directly leaks mocks -- pointing at a
|
||||
# deleted tmp_path -- into every later test that imports api_v3.
|
||||
monkeypatch.setattr(api_v3, "config_manager", cm, raising=False)
|
||||
monkeypatch.setattr(api_v3, "plugin_manager", pm, raising=False)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
return app.test_client()
|
||||
|
||||
return _setup
|
||||
|
||||
|
||||
def _get(c):
|
||||
return c.get("/api/v3/plugins/reconciliation-status").get_json()["data"]
|
||||
|
||||
|
||||
class TestStaleFindingsAreDroppedByTheEndpoint:
|
||||
def test_a_plugin_now_in_config_is_no_longer_reported(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]},
|
||||
config={"football-scoreboard": {"enabled": True}})
|
||||
# The regression: this kept being reported for hours after it resolved.
|
||||
assert _get(c)["unresolved"] == []
|
||||
|
||||
def test_a_plugin_now_installed_is_no_longer_reported(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "odds-ticker", "type": ON_DISK}]},
|
||||
installed=["odds-ticker"])
|
||||
assert _get(c)["unresolved"] == []
|
||||
|
||||
def test_a_finding_that_still_holds_is_kept(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "ghost-plugin", "type": ON_DISK}]})
|
||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["ghost-plugin"]
|
||||
|
||||
def test_the_reported_device_verdict_clears(self, client):
|
||||
"""The five findings the live device served, against its real state."""
|
||||
installed = ["football-scoreboard", "ledmatrix-weather",
|
||||
"odds-ticker", "starlark-apps"]
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": p, "type": IN_CONFIG} for p in installed]},
|
||||
config={p: {"enabled": True} for p in installed},
|
||||
installed=installed)
|
||||
assert _get(c)["unresolved"] == []
|
||||
|
||||
|
||||
class TestTheEndpointStaysRobust:
|
||||
def test_an_empty_verdict_is_passed_through(self, client):
|
||||
c = client({"done": True, "unresolved": []})
|
||||
assert _get(c) == {"done": True, "unresolved": []}
|
||||
|
||||
def test_a_broken_config_manager_leaves_findings_untouched(self, client):
|
||||
"""Best-effort: a stale warning beats a failed endpoint."""
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "football-scoreboard", "type": IN_CONFIG}]})
|
||||
api_v3.config_manager.load_config.side_effect = OSError("config unreadable")
|
||||
body = _get(c)
|
||||
assert [e["plugin_id"] for e in body["unresolved"]] == ["football-scoreboard"]
|
||||
|
||||
def test_a_run_still_in_progress_is_reported_as_such(self, client):
|
||||
c = client({"done": False, "unresolved": []})
|
||||
assert _get(c)["done"] is False
|
||||
|
||||
|
||||
class TestTheFilterUsesTheReconcilersOwnRules:
|
||||
"""A looser definition of "in config" or "on disk" clears findings that are
|
||||
still true. Both cases raised by CodeRabbit on #553."""
|
||||
|
||||
def test_a_secrets_key_does_not_clear_an_in_config_finding(self, client):
|
||||
# load_config() merges the secrets file in, so 'data' appears in the
|
||||
# config dict -- but it is not a plugin. A plain set(config) would treat
|
||||
# it as one and clear this finding.
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "data", "type": IN_CONFIG}]},
|
||||
config={"data": {"mode": "nfl_recent"}},
|
||||
secrets={"data": {"mode": "nfl_recent"}})
|
||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["data"]
|
||||
|
||||
def test_a_system_key_does_not_clear_an_in_config_finding(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "display", "type": IN_CONFIG}]},
|
||||
config={"display": {"hardware": {}}})
|
||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["display"]
|
||||
|
||||
def test_a_non_dict_value_does_not_clear_an_in_config_finding(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "timezone", "type": IN_CONFIG}]},
|
||||
config={"timezone": "America/Chicago"})
|
||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["timezone"]
|
||||
|
||||
def test_an_unparseable_manifest_does_not_count_as_installed(self, client):
|
||||
# Otherwise a corrupt file clears a live "in config but not on disk"
|
||||
# finding on the strength of something nothing can read.
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "broken-plugin", "type": ON_DISK}]},
|
||||
corrupt=["broken-plugin"])
|
||||
assert [e["plugin_id"] for e in _get(c)["unresolved"]] == ["broken-plugin"]
|
||||
|
||||
def test_a_standalone_backup_dir_does_not_count_as_installed(self, client):
|
||||
c = client({"done": True, "unresolved": [
|
||||
{"plugin_id": "weather.standalone-backup-20260101", "type": ON_DISK}]},
|
||||
installed=["weather.standalone-backup-20260101"])
|
||||
assert len(_get(c)["unresolved"]) == 1
|
||||
@@ -6,6 +6,7 @@ frame for the whole display_duration instead of rotating on -- the same class
|
||||
of defect as a display() that returns None.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
@@ -197,6 +198,50 @@ class TestInstalledAppsTakeTurns:
|
||||
assert p.display(force_clear=True) is False
|
||||
|
||||
|
||||
class TestManifestLockSidecarIsStable:
|
||||
"""_save_manifest and _update_manifest_safe must lock a sidecar file that
|
||||
a manifest write never replaces. manifest.json itself is swapped for a
|
||||
fresh inode by every atomic write (temp file + rename); a lock taken on
|
||||
manifest.json directly would not exclude a second locker whose fresh
|
||||
os.open() lands on that new inode right after a write, so two writers
|
||||
could still race each other despite each believing it held the lock.
|
||||
web_interface._starlark_manifest_lock (web_interface/blueprints/api_v3)
|
||||
must lock this same sidecar name for that guarantee to hold across both
|
||||
the plugin-loaded and standalone paths.
|
||||
"""
|
||||
|
||||
def _plugin_with_manifest_dir(self, manager_module, tmp_path):
|
||||
p = _plugin(manager_module)
|
||||
p.manifest_file = tmp_path / "manifest.json"
|
||||
p.manifest_lock_file = tmp_path / "manifest.json.lock"
|
||||
return p
|
||||
|
||||
def test_the_lock_sidecar_is_not_the_manifest_file(self, manager_module, tmp_path):
|
||||
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
|
||||
assert p.manifest_lock_file != p.manifest_file
|
||||
assert p.manifest_lock_file.name == "manifest.json.lock"
|
||||
|
||||
def test_the_locked_files_inode_survives_repeated_writes(self, manager_module, tmp_path):
|
||||
p = self._plugin_with_manifest_dir(manager_module, tmp_path)
|
||||
|
||||
assert p._save_manifest({"apps": {}})
|
||||
lock_ino_before = p.manifest_lock_file.stat().st_ino
|
||||
|
||||
for app_id in ("one", "two", "three"):
|
||||
def _update(manifest, app_id=app_id):
|
||||
manifest.setdefault("apps", {})[app_id] = {"enabled": True}
|
||||
assert p._update_manifest_safe(_update)
|
||||
|
||||
lock_ino_after = p.manifest_lock_file.stat().st_ino
|
||||
assert lock_ino_after == lock_ino_before, (
|
||||
"the locked file's inode changed across writes -- a locker that "
|
||||
"opened it before this write and one that opens it after would "
|
||||
"no longer contend for the same lock")
|
||||
|
||||
manifest = json.loads(p.manifest_file.read_text())
|
||||
assert set(manifest["apps"]) == {"one", "two", "three"}
|
||||
|
||||
|
||||
class TestAnimationsRunAtFrameRate:
|
||||
def test_the_plugin_asks_for_the_high_fps_loop(self, manager_module):
|
||||
"""The controller reads this attribute; a multi-frame app is otherwise
|
||||
|
||||
@@ -470,7 +470,10 @@ class TestApiBoundsMatchValidate:
|
||||
"""Extract the numeric_fields map from api_v3 without importing Flask."""
|
||||
import ast
|
||||
import pathlib
|
||||
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
|
||||
# api_v3 is a package; the routes are spread across its modules.
|
||||
src = '\n'.join(
|
||||
p.read_text() for p in
|
||||
sorted(pathlib.Path('web_interface/blueprints/api_v3').glob('*.py')))
|
||||
tree = ast.parse(src)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Assign):
|
||||
|
||||
@@ -390,7 +390,7 @@ class TestConfigAPI:
|
||||
class TestSystemAPI:
|
||||
"""Test system API endpoints."""
|
||||
|
||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
||||
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||
def test_get_system_status(self, mock_subprocess, client):
|
||||
"""Test getting system status."""
|
||||
# The endpoint returns 503 without psutil, which is an optional
|
||||
@@ -407,7 +407,7 @@ class TestSystemAPI:
|
||||
data = json.loads(response.data)
|
||||
assert 'service' in data or 'status' in data or 'active' in data
|
||||
|
||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
||||
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||
def test_get_system_version(self, mock_subprocess, client):
|
||||
"""Test getting system version."""
|
||||
mock_result = MagicMock()
|
||||
@@ -421,7 +421,7 @@ class TestSystemAPI:
|
||||
data = json.loads(response.data)
|
||||
assert 'version' in data.get('data', {}) or 'version' in data
|
||||
|
||||
@patch('web_interface.blueprints.api_v3.subprocess')
|
||||
@patch('web_interface.blueprints.api_v3.system.subprocess')
|
||||
def test_execute_system_action(self, mock_subprocess, client):
|
||||
"""Test executing system action."""
|
||||
mock_result = MagicMock()
|
||||
@@ -502,7 +502,7 @@ class TestDisplayAPI:
|
||||
if response.status_code in [200, 201]:
|
||||
assert api_v3.cache_manager.set.called
|
||||
|
||||
@patch('web_interface.blueprints.api_v3._ensure_cache_manager')
|
||||
@patch('web_interface.blueprints.api_v3.display._ensure_cache_manager')
|
||||
def test_stop_on_demand_display(self, mock_ensure_cache, client):
|
||||
"""Test stopping on-demand display."""
|
||||
|
||||
|
||||
@@ -114,7 +114,11 @@ class TestHandlersCarryDetail:
|
||||
"""
|
||||
import ast
|
||||
|
||||
src = open("web_interface/blueprints/api_v3.py").read()
|
||||
# api_v3 is a package; the routes are spread across its modules.
|
||||
import pathlib
|
||||
src = "\n".join(
|
||||
p.read_text() for p in
|
||||
sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")))
|
||||
tree = ast.parse(src)
|
||||
|
||||
# This used to match one exact message string, so a handler that wrote
|
||||
|
||||
@@ -139,6 +139,58 @@ class TestRequestValidation:
|
||||
assert post(client, options="{}").status_code == 200
|
||||
assert restore.call_args[0][2].restore_config is True
|
||||
|
||||
def test_unknown_option_key_is_refused(self, client, restore):
|
||||
# Regression: opts_dict.get('restore_secrets', True) silently
|
||||
# ignores a typo'd/renamed key like "restoreSecrets" and keeps the
|
||||
# True default, restoring secrets a caller's request clearly meant
|
||||
# to exclude -- with no indication anything was wrong.
|
||||
response = post(client, options=json.dumps({"restoreSecrets": False}))
|
||||
assert response.status_code == 400
|
||||
assert "Unknown restore option" in response.get_json()["message"]
|
||||
assert "restoreSecrets" in response.get_json()["message"]
|
||||
restore.assert_not_called()
|
||||
|
||||
def test_known_and_unknown_keys_together_are_refused(self, client, restore):
|
||||
response = post(client, options=json.dumps({
|
||||
"restore_secrets": False, "restore_everything": True}))
|
||||
assert response.status_code == 400
|
||||
restore.assert_not_called()
|
||||
|
||||
|
||||
class TestOptionsAreBooleanAware:
|
||||
"""Regression: bool("false") is True in Python.
|
||||
|
||||
Every restore flag used bare bool() coercion, so a caller that sends its
|
||||
options as JSON strings rather than real booleans -- a form field, a
|
||||
hand-built request -- had `{"restore_secrets": "false"}` restore secrets
|
||||
anyway, the opposite of what was asked. Fixed with the same
|
||||
string-aware `_coerce_to_bool` already used for checkbox-style config
|
||||
fields elsewhere in this package (config.py, plugins.py).
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("false", False), ("False", False), ("FALSE", False),
|
||||
("0", False),
|
||||
("true", True), ("True", True), ("1", True),
|
||||
])
|
||||
def test_string_valued_flags_are_parsed_not_just_truthy(
|
||||
self, client, restore, raw, expected):
|
||||
post(client, options=json.dumps({"restore_secrets": raw}))
|
||||
assert restore.call_args[0][2].restore_secrets is expected
|
||||
|
||||
def test_a_string_false_does_not_restore_secrets(self, client, restore):
|
||||
# The exact shape of the bug: a truthy non-empty string coerced by
|
||||
# bare bool() to True regardless of its contents.
|
||||
post(client, options=json.dumps({"restore_secrets": "false"}))
|
||||
assert restore.call_args[0][2].restore_secrets is False
|
||||
|
||||
def test_real_json_booleans_still_work(self, client, restore):
|
||||
post(client, options=json.dumps({"restore_secrets": False,
|
||||
"restore_config": True}))
|
||||
options = restore.call_args[0][2]
|
||||
assert options.restore_secrets is False
|
||||
assert options.restore_config is True
|
||||
|
||||
|
||||
class TestSuccess:
|
||||
def test_success_returns_the_result(self, client, restore):
|
||||
@@ -230,7 +282,7 @@ class TestPluginReinstall:
|
||||
def test_missing_store_manager_is_reported_per_plugin(self, client, restore):
|
||||
restore.return_value = FakeResult(plugins_to_install=[{"plugin_id": "clock"}])
|
||||
api_v3.plugin_store_manager = None
|
||||
with patch("web_interface.blueprints.api_v3.plugin_store_manager", None):
|
||||
with patch("web_interface.blueprints.api_v3.backup.plugin_store_manager", None):
|
||||
body = post(client).get_json()
|
||||
assert body["data"]["plugins_failed"][0]["error"] == "Store manager unavailable"
|
||||
|
||||
|
||||
@@ -77,7 +77,10 @@ class TestTheRoutesExistAtAll:
|
||||
oauth = Path(project_root) / 'web_interface/static/v3/js/widgets/google-oauth.js'
|
||||
assert '/api/v3/plugins/calendar/list-calendars' in picker.read_text(encoding='utf-8')
|
||||
assert '/api/v3/plugins/calendar/authenticate' in oauth.read_text(encoding='utf-8')
|
||||
source = (Path(project_root) / 'web_interface/blueprints/api_v3.py').read_text(encoding='utf-8')
|
||||
# api_v3 is a package now, so the route strings are spread across its
|
||||
# modules; read the whole directory rather than one file.
|
||||
pkg = Path(project_root) / 'web_interface/blueprints/api_v3'
|
||||
source = "\n".join(f.read_text(encoding='utf-8') for f in sorted(pkg.glob('*.py')))
|
||||
assert "'/plugins/calendar/list-calendars'" in source
|
||||
assert "'/plugins/calendar/authenticate'" in source
|
||||
|
||||
@@ -360,6 +363,22 @@ class TestDiagnosticsAreRedacted:
|
||||
assert 'hunter2' not in error, error
|
||||
assert '<redacted>' in error, error
|
||||
|
||||
def test_script_stderr_is_redacted_in_the_log_too(self, tmp_path, caplog):
|
||||
# Regression: the return value went through redact_text (asserted
|
||||
# above), but the logger.error call right next to it logged `raw`
|
||||
# verbatim -- a script that handles OAuth client secrets and can
|
||||
# quote them in its stderr, landing unredacted in the log (CWE-532).
|
||||
script = tmp_path / 'calendar_registration.py'
|
||||
script.write_text(
|
||||
'import sys\n'
|
||||
'sys.stderr.write("boom client_secret=hunter2 more\\n")\n',
|
||||
encoding='utf-8')
|
||||
with caplog.at_level('ERROR', logger=mod.logger.name):
|
||||
mod._run_calendar_registration(tmp_path, '')
|
||||
logged = '\n'.join(r.getMessage() for r in caplog.records)
|
||||
assert 'hunter2' not in logged, logged
|
||||
assert '<redacted>' in logged, logged
|
||||
|
||||
def test_a_failing_script_payload_is_redacted(self, client):
|
||||
(client.plugin_dir / 'credentials.json').write_text('{}', encoding='utf-8')
|
||||
(client.plugin_dir / 'calendar_registration.py').write_text(
|
||||
|
||||
@@ -10,8 +10,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SOURCE = (Path(__file__).resolve().parents[2]
|
||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
||||
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
||||
/ "web_interface" / "blueprints" / "api_v3")
|
||||
|
||||
#: Objects that still hold submitted secret values at the point these log
|
||||
#: calls run. Interpolating one whole into a log message leaks credentials.
|
||||
@@ -19,7 +19,7 @@ UNREDACTED = ("plugin_config", "secrets_config", "current_secrets")
|
||||
|
||||
|
||||
def _logging_lines():
|
||||
for number, line in enumerate(SOURCE.read_text(encoding="utf-8").splitlines(), 1):
|
||||
for number, line in enumerate("\n".join(p.read_text(encoding="utf-8") for p in sorted(API_V3_PKG.glob("*.py"))).splitlines(), 1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
@@ -15,8 +15,18 @@ from pathlib import Path
|
||||
|
||||
from src.web_interface.secret_helpers import find_secret_fields, separate_secrets
|
||||
|
||||
API_V3_PATH = (Path(__file__).resolve().parents[2]
|
||||
/ "web_interface" / "blueprints" / "api_v3.py")
|
||||
API_V3_PKG = (Path(__file__).resolve().parents[2]
|
||||
/ "web_interface" / "blueprints" / "api_v3")
|
||||
|
||||
|
||||
def _api_v3_source() -> str:
|
||||
"""Every module of the api_v3 package as one string.
|
||||
|
||||
It used to be a single file; the inline copies this guards against could
|
||||
now reappear in any module of the package.
|
||||
"""
|
||||
return "\n".join(p.read_text(encoding="utf-8")
|
||||
for p in sorted(API_V3_PKG.glob("*.py")))
|
||||
|
||||
# The migration is complete: any inline reimplementation is a regression.
|
||||
EXPECTED_INLINE_COPIES = 0
|
||||
@@ -24,7 +34,7 @@ EXPECTED_INLINE_COPIES = 0
|
||||
|
||||
class TestNoInlineCopies:
|
||||
def _count(self, name: str) -> int:
|
||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||
source = _api_v3_source()
|
||||
return len(re.findall(rf"^\s*def {name}\(", source, flags=re.MULTILINE))
|
||||
|
||||
def test_no_inline_find_secret_fields(self):
|
||||
@@ -46,7 +56,7 @@ class TestNoInlineCopies:
|
||||
def test_canonical_import_present(self):
|
||||
# Tripwire: the endpoints still need the helpers, so removing the
|
||||
# import means either dead secret handling or a new local copy.
|
||||
source = API_V3_PATH.read_text(encoding="utf-8")
|
||||
source = _api_v3_source()
|
||||
assert re.search(
|
||||
r"from src\.web_interface\.secret_helpers import .*find_secret_fields",
|
||||
source,
|
||||
|
||||
@@ -63,14 +63,14 @@ class TestRoutesAreRegistered:
|
||||
|
||||
class TestInstallPixlet:
|
||||
def test_it_does_not_404(self, client):
|
||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||
run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||
assert resp.status_code != 404, "the route is still missing"
|
||||
assert resp.get_json().get('message') != 'Resource not found'
|
||||
|
||||
def test_success_is_reported_in_the_shape_the_button_reads(self, client):
|
||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||
run.return_value = MagicMock(returncode=0, stdout="done", stderr="")
|
||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||
body = resp.get_json()
|
||||
@@ -78,7 +78,7 @@ class TestInstallPixlet:
|
||||
assert 'message' in body, "the JS shows data.message on success"
|
||||
|
||||
def test_a_failed_download_says_why(self, client):
|
||||
with patch('web_interface.blueprints.api_v3.subprocess.run') as run:
|
||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run') as run:
|
||||
run.return_value = MagicMock(returncode=1, stdout="", stderr="no such release")
|
||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||
body = resp.get_json()
|
||||
@@ -88,7 +88,7 @@ class TestInstallPixlet:
|
||||
|
||||
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
||||
import subprocess as sp
|
||||
with patch('web_interface.blueprints.api_v3.subprocess.run',
|
||||
with patch('web_interface.blueprints.api_v3.starlark.subprocess.run',
|
||||
side_effect=sp.TimeoutExpired(cmd='x', timeout=300)):
|
||||
resp = client.post('/api/v3/starlark/install-pixlet')
|
||||
assert resp.get_json()['status'] == 'error'
|
||||
@@ -395,6 +395,164 @@ class TestTheManifestStaysRelocatable:
|
||||
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
||||
|
||||
|
||||
class TestManifestLockPreventsLostUpdates:
|
||||
"""The standalone manifest fallback (no plugin instance loaded) reads,
|
||||
mutates and writes manifest.json with no coordination across requests.
|
||||
Each write is atomic on its own (temp file + rename), but two concurrent
|
||||
read-modify-write cycles can still race: both read the same starting
|
||||
manifest, and the second write silently discards whatever the first one
|
||||
added. _starlark_manifest_lock closes that window -- mirrors
|
||||
StarlarkAppsPlugin._update_manifest_safe, which already does this when
|
||||
the plugin instance is loaded.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def starlark_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_LOCK_FILE', apps_dir / 'manifest.json.lock')
|
||||
module._write_starlark_manifest({'apps': {}})
|
||||
return apps_dir
|
||||
|
||||
def test_the_locked_file_survives_a_manifest_write(self, starlark_dir):
|
||||
"""_write_starlark_manifest replaces manifest.json with a fresh inode
|
||||
on every write (temp file + rename). If the lock were taken on that
|
||||
same file, a second locker's fresh os.open() right after the rename
|
||||
would land on the new inode -- unguarded, because only the old,
|
||||
now-orphaned inode was ever locked -- and two writers could race
|
||||
despite each believing it "held the lock" (see the docstring on
|
||||
_starlark_manifest_lock). Locking a sidecar path that no write ever
|
||||
touches or renames over closes that: the inode identity of what gets
|
||||
locked must not change across writes.
|
||||
"""
|
||||
import os
|
||||
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
with module._starlark_manifest_lock():
|
||||
manifest = module._read_starlark_manifest()
|
||||
lock_ino_before = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
|
||||
|
||||
for app_id in ('one', 'two', 'three'):
|
||||
with module._starlark_manifest_lock():
|
||||
manifest = module._read_starlark_manifest()
|
||||
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||
assert module._write_starlark_manifest(manifest)
|
||||
|
||||
lock_ino_after = os.stat(module._STARLARK_MANIFEST_LOCK_FILE).st_ino
|
||||
assert lock_ino_after == lock_ino_before, (
|
||||
"the locked file's inode changed across writes -- a locker that "
|
||||
"opened it before this write and one that opens it after would "
|
||||
"no longer contend for the same lock")
|
||||
|
||||
def test_two_concurrent_updates_are_both_kept(self, starlark_dir):
|
||||
import threading
|
||||
import time as _time
|
||||
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
def add_app(app_id):
|
||||
with module._starlark_manifest_lock():
|
||||
manifest = module._read_starlark_manifest()
|
||||
# Widen the window between read and write. Without the lock
|
||||
# both threads read here before either writes, and whichever
|
||||
# writes second overwrites the other's addition; with the
|
||||
# lock, the second thread cannot even start its read until
|
||||
# the first has written and released.
|
||||
_time.sleep(0.05)
|
||||
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||
module._write_starlark_manifest(manifest)
|
||||
|
||||
threads = [threading.Thread(target=add_app, args=(app_id,))
|
||||
for app_id in ('a', 'b')]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=5)
|
||||
|
||||
manifest = module._read_starlark_manifest()
|
||||
assert set(manifest['apps']) == {'a', 'b'}, (
|
||||
"a concurrent update was lost: %r" % (manifest,))
|
||||
|
||||
def test_the_lock_is_reentrant_safe_across_sequential_calls(self, starlark_dir):
|
||||
"""Not reentrant within one thread -- just that using it twice in a
|
||||
row (the ordinary case: one request, then the next) works cleanly
|
||||
and does not leak the lock file descriptor or leave it locked."""
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
for app_id in ('first', 'second'):
|
||||
with module._starlark_manifest_lock():
|
||||
manifest = module._read_starlark_manifest()
|
||||
manifest.setdefault('apps', {})[app_id] = {'enabled': True}
|
||||
module._write_starlark_manifest(manifest)
|
||||
|
||||
manifest = module._read_starlark_manifest()
|
||||
assert set(manifest['apps']) == {'first', 'second'}
|
||||
|
||||
|
||||
class TestConfigAndManifestStayInSync:
|
||||
"""Standalone-mode PUT /starlark/apps/<id>/config (no plugin instance
|
||||
loaded) writes config.json and then the manifest. If the manifest write
|
||||
fails after config.json was already written, the two disagree about
|
||||
what was saved unless config.json is rolled back.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def app_dir(self, tmp_path, monkeypatch):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
apps_dir = tmp_path / "starlark-apps"
|
||||
apps_dir.mkdir()
|
||||
monkeypatch.setattr(module, '_STARLARK_APPS_DIR', apps_dir)
|
||||
monkeypatch.setattr(module, '_STARLARK_MANIFEST_FILE', apps_dir / 'manifest.json')
|
||||
one_app_dir = apps_dir / 'demo'
|
||||
one_app_dir.mkdir()
|
||||
module._write_starlark_manifest({'apps': {'demo': {'name': 'Demo', 'enabled': True}}})
|
||||
return one_app_dir
|
||||
|
||||
def test_manifest_write_failure_rolls_back_an_existing_config_json(self, client, app_dir):
|
||||
config_file = app_dir / 'config.json'
|
||||
config_file.write_text(json.dumps({'existing': 'value'}))
|
||||
|
||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||
json={'new_field': 'x'})
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert json.loads(config_file.read_text()) == {'existing': 'value'}, (
|
||||
"config.json kept the new value even though the manifest write "
|
||||
"that was supposed to follow it failed")
|
||||
|
||||
def test_manifest_write_failure_removes_a_freshly_created_config_json(self, client, app_dir):
|
||||
config_file = app_dir / 'config.json'
|
||||
assert not config_file.exists()
|
||||
|
||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
||||
patch('web_interface.blueprints.api_v3._write_starlark_manifest', return_value=False):
|
||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||
json={'new_field': 'x'})
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert not config_file.exists(), (
|
||||
"config.json was left behind even though the manifest write "
|
||||
"that was supposed to follow it failed")
|
||||
|
||||
def test_success_updates_both_config_and_manifest(self, client, app_dir):
|
||||
from web_interface.blueprints import api_v3 as module
|
||||
|
||||
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
||||
resp = client.put('/api/v3/starlark/apps/demo/config',
|
||||
json={'new_field': 'x'})
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert json.loads((app_dir / 'config.json').read_text())['new_field'] == 'x'
|
||||
manifest = json.loads(module._STARLARK_MANIFEST_FILE.read_text())
|
||||
assert manifest['apps']['demo']['config']['new_field'] == 'x'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The store loaded, then stopped loading, and nothing anywhere said why.
|
||||
#
|
||||
|
||||
@@ -19,7 +19,12 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
API_V3 = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
|
||||
# api_v3 is a package; a sudo systemctl call can live in any of its modules.
|
||||
API_V3_PKG = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3"
|
||||
|
||||
|
||||
def _api_v3_source() -> str:
|
||||
return "\n".join(p.read_text() for p in sorted(API_V3_PKG.glob("*.py")))
|
||||
SUDOERS_SCRIPT = PROJECT_ROOT / "scripts" / "install" / "configure_web_sudo.sh"
|
||||
|
||||
|
||||
@@ -51,7 +56,7 @@ def _granted_systemctl_rules(script: str) -> set[tuple[str, str]]:
|
||||
|
||||
|
||||
def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||
calls = _sudo_systemctl_calls(_api_v3_source())
|
||||
rules = _granted_systemctl_rules(SUDOERS_SCRIPT.read_text())
|
||||
|
||||
assert calls, "expected to find sudo systemctl calls in api_v3.py"
|
||||
@@ -68,7 +73,7 @@ def test_every_sudo_systemctl_call_is_granted() -> None:
|
||||
def test_units_are_fully_qualified() -> None:
|
||||
"""Privileged systemctl calls must name the unit as <name>.service so they
|
||||
match the sudoers grants, which use the fully-qualified unit name."""
|
||||
calls = _sudo_systemctl_calls(API_V3.read_text())
|
||||
calls = _sudo_systemctl_calls(_api_v3_source())
|
||||
unqualified = {(v, u) for v, u in calls if not u.endswith(".service")}
|
||||
assert not unqualified, (
|
||||
"sudo systemctl calls must use fully-qualified .service unit names: "
|
||||
|
||||
Reference in New Issue
Block a user