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 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>
909 lines
41 KiB
Python
909 lines
41 KiB
Python
"""The Starlark routes the frontend calls must exist.
|
|
|
|
`plugins_manager.js` posts to /api/v3/starlark/install-pixlet and then reloads
|
|
/api/v3/starlark/status. Neither route existed: #330 rewrote api_v3.py and
|
|
dropped all thirteen Starlark routes that #253 had added, so both calls fell
|
|
through to Flask's 404 handler, which answers
|
|
|
|
{"status": "error", "message": "Resource not found"}
|
|
|
|
and the button reported "Pixlet install failed: Resource not found" -- a
|
|
message that names neither the resource nor the cause.
|
|
|
|
These assert the routes are registered and answer in the shape the frontend
|
|
reads, so a future rewrite of this file cannot silently drop them again.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from web_interface.app import app
|
|
app.config['TESTING'] = True
|
|
with app.test_client() as c:
|
|
yield c
|
|
|
|
|
|
class TestRoutesAreRegistered:
|
|
"""The failure was a missing route, so check the URL map directly.
|
|
|
|
All thirteen, not just the two the Pixlet button needs: #330 dropped the
|
|
lot, and the app store page is built on repository/browse,
|
|
repository/categories and repository/install, which 404 the same way.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("rule,method", [
|
|
("/api/v3/starlark/install-pixlet", "POST"),
|
|
("/api/v3/starlark/status", "GET"),
|
|
("/api/v3/starlark/apps", "GET"),
|
|
("/api/v3/starlark/upload", "POST"),
|
|
("/api/v3/starlark/repository/browse", "GET"),
|
|
("/api/v3/starlark/repository/categories", "GET"),
|
|
("/api/v3/starlark/repository/install", "POST"),
|
|
("/api/v3/starlark/apps/<app_id>", "GET"),
|
|
("/api/v3/starlark/apps/<app_id>", "DELETE"),
|
|
("/api/v3/starlark/apps/<app_id>/config", "GET"),
|
|
("/api/v3/starlark/apps/<app_id>/config", "PUT"),
|
|
("/api/v3/starlark/apps/<app_id>/toggle", "POST"),
|
|
("/api/v3/starlark/apps/<app_id>/render", "POST"),
|
|
])
|
|
def test_route_exists(self, rule, method):
|
|
from web_interface.app import app
|
|
matches = [r for r in app.url_map.iter_rules()
|
|
if r.rule == rule and method in r.methods]
|
|
assert matches, (
|
|
f"{method} {rule} is not registered; the frontend calls it and "
|
|
"would get Flask's generic 'Resource not found'")
|
|
|
|
|
|
class TestInstallPixlet:
|
|
def test_it_does_not_404(self, client):
|
|
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.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()
|
|
assert body['status'] == 'success', body
|
|
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.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()
|
|
assert body['status'] == 'error'
|
|
assert 'no such release' in body['message'], \
|
|
"the installer's own stderr is what tells the user what went wrong"
|
|
|
|
def test_a_timeout_is_reported_rather_than_hanging(self, client):
|
|
import subprocess as sp
|
|
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'
|
|
assert 'timed out' in resp.get_json()['message'].lower()
|
|
|
|
|
|
class TestStarlarkStatus:
|
|
def test_it_does_not_404(self, client):
|
|
resp = client.get('/api/v3/starlark/status')
|
|
assert resp.status_code != 404, "the route is still missing"
|
|
assert resp.get_json().get('message') != 'Resource not found'
|
|
|
|
def test_it_reports_pixlet_availability_without_the_plugin_loaded(self, client):
|
|
# The status call runs before install too -- it must answer even when
|
|
# starlark-apps is not loaded, which is the state a user is in when
|
|
# they press the install button for the first time.
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None):
|
|
resp = client.get('/api/v3/starlark/status')
|
|
body = resp.get_json()
|
|
assert body['status'] == 'success', body
|
|
assert 'pixlet_available' in body
|
|
assert body['plugin_loaded'] is False
|
|
|
|
|
|
class TestTheInstallerScriptIsActuallyThere:
|
|
def test_download_pixlet_script_exists_and_is_executable(self):
|
|
# install_pixlet chmods and runs this; a missing file is the one error
|
|
# it reports as a 404 of its own, which would look identical to the
|
|
# bug being fixed here.
|
|
import os
|
|
from pathlib import Path
|
|
from web_interface.blueprints.api_v3 import PROJECT_ROOT
|
|
script = Path(PROJECT_ROOT) / 'scripts' / 'download_pixlet.sh'
|
|
assert script.is_file(), f"{script} is missing; install_pixlet would 404"
|
|
assert os.access(script, os.R_OK)
|
|
|
|
|
|
class TestTheAppStoreFlow:
|
|
"""Browsing and installing from the Tronbyte repository.
|
|
|
|
These are the calls the app store page makes. Each returned the generic
|
|
"Resource not found" before this change, which is indistinguishable from
|
|
an empty store.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def offline_repo(self):
|
|
"""No live GitHub calls from the test suite.
|
|
|
|
browse and categories reach _get_tronbyte_repository_class() and then
|
|
list_all_apps_cached(); with a cold server-side cache that is a real
|
|
network request, which makes the run slow, rate-limitable, and able to
|
|
pass on a 500 because these assertions only check for a 404.
|
|
"""
|
|
repo = MagicMock()
|
|
# Matches what the real list_all_apps_cached returns; the handler
|
|
# indexes every one of these keys.
|
|
repo.return_value.list_all_apps_cached.return_value = {
|
|
'apps': [{'id': 'quoteoftheday', 'name': 'A Quote A Day',
|
|
'category': 'text'}],
|
|
'categories': ['text'],
|
|
'authors': ['someone'],
|
|
'count': 1,
|
|
'cached': True,
|
|
}
|
|
repo.return_value.get_rate_limit_info.return_value = {'remaining': 5000}
|
|
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
return_value=repo):
|
|
yield repo
|
|
|
|
def test_browse_does_not_404(self, client, offline_repo):
|
|
resp = client.get('/api/v3/starlark/repository/browse')
|
|
assert resp.status_code != 404, "the store cannot list anything"
|
|
assert resp.get_json().get('message') != 'Resource not found'
|
|
|
|
def test_browse_returns_the_apps_the_store_lists(self, client, offline_repo):
|
|
resp = client.get('/api/v3/starlark/repository/browse')
|
|
body = resp.get_json()
|
|
assert body['status'] == 'success', body
|
|
assert any(a.get('id') == 'quoteoftheday' for a in body.get('apps', [])), body
|
|
|
|
def test_categories_does_not_404(self, client, offline_repo):
|
|
resp = client.get('/api/v3/starlark/repository/categories')
|
|
assert resp.status_code != 404
|
|
assert resp.get_json().get('message') != 'Resource not found'
|
|
|
|
def test_no_live_network_call_is_made(self, client, offline_repo):
|
|
client.get('/api/v3/starlark/repository/browse')
|
|
assert offline_repo.called, \
|
|
"the route did not go through the patched repository class"
|
|
|
|
def test_installed_apps_list_does_not_404(self, client):
|
|
resp = client.get('/api/v3/starlark/apps')
|
|
assert resp.status_code != 404
|
|
assert resp.get_json().get('message') != 'Resource not found'
|
|
|
|
def test_repository_install_rejects_a_missing_body_rather_than_404ing(self, client):
|
|
# A 400/422 here is the route working: it received the call and said
|
|
# what was wrong. A 404 means it was never reached at all.
|
|
resp = client.post('/api/v3/starlark/repository/install',
|
|
json={}, content_type='application/json')
|
|
assert resp.status_code != 404, "the install route is still missing"
|
|
assert resp.get_json().get('message') != 'Resource not found'
|
|
|
|
def test_upload_rejects_an_empty_post_rather_than_404ing(self, client):
|
|
resp = client.post('/api/v3/starlark/upload')
|
|
assert resp.status_code != 404
|
|
assert resp.get_json().get('message') != 'Resource not found'
|
|
|
|
|
|
class TestNoStarlarkRouteIsMissing:
|
|
"""A single check that the whole set is present.
|
|
|
|
#330 removed all thirteen at once by rewriting this file. One assertion
|
|
over the frontend's own list is what would have caught that.
|
|
"""
|
|
|
|
def test_every_endpoint_the_frontend_calls_is_registered(self):
|
|
import re
|
|
from pathlib import Path
|
|
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
|
from web_interface.app import app
|
|
|
|
root = Path(__file__).resolve().parent.parent.parent
|
|
js = (root / 'web_interface' / 'static' / 'v3' / 'plugins_manager.js').read_text()
|
|
|
|
# The frontend builds some of these with template literals, e.g.
|
|
# `/api/v3/starlark/apps/${appId}/toggle`. Substitute a placeholder so
|
|
# the URL is concrete, then let Werkzeug match it the way a request
|
|
# would -- string comparison cannot see <app_id> rules.
|
|
raw = set(re.findall(r"[\'\"`](/api/v3/starlark/[^\'\"`\s]*)", js))
|
|
urls = set()
|
|
for u in raw:
|
|
u = re.sub(r"\$\{[^}]*\}", "probe", u)
|
|
urls.add(u.rstrip('/') or u)
|
|
assert urls, "found no starlark calls in the frontend -- did the file move?"
|
|
|
|
adapter = app.url_map.bind('localhost')
|
|
missing = []
|
|
for u in sorted(urls):
|
|
try:
|
|
adapter.match(u, method='GET')
|
|
except MethodNotAllowed:
|
|
pass # route exists, just not for GET -- fine
|
|
except NotFound:
|
|
missing.append(u)
|
|
assert not missing, f"the frontend calls these and they are not registered: {missing}"
|
|
|
|
|
|
class TestInstalledAppsAppearWithTheOtherPlugins:
|
|
"""An installed .star app must be manageable like any other plugin.
|
|
|
|
#253 surfaced installed apps in /plugins/installed as `starlark:<app_id>`
|
|
entries, so they could be seen and enabled/disabled from the same list as
|
|
everything else, and routed `starlark:` toggles to the Starlark manifest.
|
|
#330 removed both. The result was an app that installs successfully, then
|
|
appears nowhere and cannot be turned on or off.
|
|
"""
|
|
|
|
APPS = {'apps': {'quoteoftheday': {'name': 'A Quote A Day', 'enabled': True}}}
|
|
|
|
def test_an_installed_app_is_listed(self, client):
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
|
resp = client.get('/api/v3/plugins/installed')
|
|
ids = [p['id'] for p in resp.get_json()['data']['plugins']]
|
|
assert 'starlark:quoteoftheday' in ids, \
|
|
"an installed Starlark app does not appear among the plugins"
|
|
|
|
def test_the_entry_carries_what_the_ui_needs(self, client):
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest', return_value=self.APPS):
|
|
resp = client.get('/api/v3/plugins/installed')
|
|
entry = next(p for p in resp.get_json()['data']['plugins']
|
|
if p['id'] == 'starlark:quoteoftheday')
|
|
assert entry['name'] == 'A Quote A Day'
|
|
assert entry['enabled'] is True
|
|
assert entry['is_starlark_app'] is True, "the UI keys its Starlark handling off this"
|
|
assert entry['category'] == 'Starlark App'
|
|
|
|
def test_a_starlark_failure_does_not_empty_the_plugin_list(self, client):
|
|
# The virtual entries are appended to the real ones; a broken manifest
|
|
# must cost the Starlark rows, not everybody else's.
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
|
side_effect=RuntimeError('boom')):
|
|
resp = client.get('/api/v3/plugins/installed')
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()['status'] == 'success'
|
|
|
|
def test_toggling_an_app_does_not_report_plugin_not_found(self, client):
|
|
written = {}
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
|
return_value={'apps': {'quoteoftheday': {'enabled': True}}}), \
|
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
|
side_effect=lambda m: written.update(m) or True):
|
|
resp = client.post('/api/v3/plugins/toggle',
|
|
json={'plugin_id': 'starlark:quoteoftheday', 'enabled': False})
|
|
body = resp.get_json()
|
|
assert body['status'] == 'success', body
|
|
assert body['enabled'] is False
|
|
assert written['apps']['quoteoftheday']['enabled'] is False, \
|
|
"the manifest was not actually updated"
|
|
|
|
def test_toggling_an_unknown_app_says_so(self, client):
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
|
return_value={'apps': {}}):
|
|
resp = client.post('/api/v3/plugins/toggle',
|
|
json={'plugin_id': 'starlark:nope', 'enabled': True})
|
|
assert resp.status_code == 404
|
|
assert 'nope' in resp.get_json()['message']
|
|
|
|
def test_a_traversal_app_id_is_rejected_before_touching_the_manifest(self, client):
|
|
resp = client.post('/api/v3/plugins/toggle',
|
|
json={'plugin_id': 'starlark:../../etc/passwd', 'enabled': True})
|
|
assert resp.status_code == 400
|
|
assert 'traversal' in resp.get_json()['message']
|
|
|
|
def test_an_app_id_the_listing_published_can_be_toggled(self, client):
|
|
"""The id here is exactly what _starlark_virtual_plugins publishes.
|
|
|
|
It used to be re-slugified on the way back in -- lowercased, with every
|
|
character outside [a-z0-9_] replaced -- so an app stored as 'My-App'
|
|
was listed as 'starlark:My-App' and looked up as 'my_app'. Toggling an
|
|
app the page had just drawn answered 404.
|
|
"""
|
|
manifest = {'apps': {'My-App': {'name': 'My App', 'enabled': False}}}
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=None), \
|
|
patch('web_interface.blueprints.api_v3._read_starlark_manifest',
|
|
return_value=manifest), \
|
|
patch('web_interface.blueprints.api_v3._write_starlark_manifest',
|
|
return_value=True) as write:
|
|
resp = client.post('/api/v3/plugins/toggle',
|
|
json={'plugin_id': 'starlark:My-App', 'enabled': True})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert write.called, "the toggle never reached the manifest"
|
|
assert manifest['apps']['My-App']['enabled'] is True
|
|
|
|
def test_a_loaded_app_missing_from_the_manifest_does_not_500(self, client):
|
|
"""_update_manifest_safe does not catch KeyError, so indexing an entry
|
|
that is not on disk yet escaped as a 500 instead of writing it."""
|
|
app = MagicMock()
|
|
app.manifest = {'enabled': False}
|
|
plugin = MagicMock()
|
|
plugin.apps = {'demo': app}
|
|
written = {}
|
|
|
|
def run_updater(fn):
|
|
fn(written)
|
|
return True
|
|
|
|
plugin._update_manifest_safe.side_effect = run_updater
|
|
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin', return_value=plugin):
|
|
resp = client.post('/api/v3/plugins/toggle',
|
|
json={'plugin_id': 'starlark:demo', 'enabled': True})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
assert written['apps']['demo']['enabled'] is True
|
|
|
|
|
|
class TestTheManifestStaysRelocatable:
|
|
"""`star_file` is joined to the app's own directory by its readers.
|
|
|
|
_standalone_render_starlark_app does `app_dir / app_data.get('star_file',
|
|
f'{app_id}.star')`, so the key's default is a bare filename. Storing an
|
|
absolute path gave it a second meaning, and Path.__truediv__ discards the
|
|
left side when the right is absolute -- which pinned the manifest to the
|
|
PROJECT_ROOT that installed it.
|
|
"""
|
|
|
|
@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')
|
|
return apps_dir
|
|
|
|
def _install(self, tmp_path):
|
|
from web_interface.blueprints import api_v3 as module
|
|
source = tmp_path / "source.star"
|
|
source.write_text("# app")
|
|
with patch.object(module, '_get_pixlet_renderer_class',
|
|
side_effect=ImportError("no pixlet here")):
|
|
assert module._install_star_file('demo', str(source), {'name': 'Demo'})
|
|
return json.loads((module._STARLARK_MANIFEST_FILE).read_text())['apps']['demo']
|
|
|
|
def test_the_star_file_is_recorded_by_name(self, starlark_dir, tmp_path):
|
|
assert self._install(tmp_path)['star_file'] == 'demo.star'
|
|
|
|
def test_the_recorded_path_is_not_absolute(self, starlark_dir, tmp_path):
|
|
"""An absolute value survives a move only by accident."""
|
|
assert not os.path.isabs(self._install(tmp_path)['star_file'])
|
|
|
|
def test_the_value_resolves_against_the_app_directory(self, starlark_dir, tmp_path):
|
|
"""Which is the one thing every reader of this key does with it."""
|
|
entry = self._install(tmp_path)
|
|
assert (starlark_dir / 'demo' / entry['star_file']).is_file()
|
|
|
|
def test_it_matches_the_default_a_reader_falls_back_to(self, starlark_dir, tmp_path):
|
|
"""Stored and defaulted values must mean the same thing."""
|
|
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.
|
|
#
|
|
# #535 restored the routes, so the 404 was gone -- but two failure modes
|
|
# underneath it produce the same blank grid, and neither could be read from
|
|
# outside. On the device this was diagnosed on, /repository/browse answered
|
|
# 200 with 1000 apps in 27s while GitHub reported 18 of 60 unauthenticated
|
|
# requests remaining, with 48 installed plugins checking for updates against
|
|
# the same budget. When that budget runs out the store goes blank and says
|
|
# nothing at all.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _repository_module():
|
|
"""Load tronbyte_repository.py the way the blueprint does."""
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
path = (Path(__file__).resolve().parents[2]
|
|
/ 'plugin-repos' / 'starlark-apps' / 'tronbyte_repository.py')
|
|
spec = importlib.util.spec_from_file_location('_tronbyte_repo_under_test', path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules['_tronbyte_repo_under_test'] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class _Resp:
|
|
"""Enough of requests.Response for the paths under test."""
|
|
|
|
def __init__(self, status_code=200, payload=None, headers=None, raises=None):
|
|
self.status_code = status_code
|
|
self._payload = payload
|
|
self.headers = headers or {}
|
|
self._raises = raises
|
|
|
|
def json(self):
|
|
if self._raises is not None:
|
|
raise self._raises
|
|
return self._payload
|
|
|
|
|
|
class TestTheJsonGuardIsNotItselfACrash:
|
|
"""`except (json.JSONDecodeError, ValueError)` with no `import json`.
|
|
|
|
Evaluating that tuple raises NameError, so the guard written for exactly
|
|
this case never ran: a non-JSON body -- a captive portal, a proxy error
|
|
page, a DNS-hijacking router answering for api.github.com -- came out as
|
|
a 500 instead of the None the caller was written to handle.
|
|
"""
|
|
|
|
def test_a_non_json_body_returns_none(self):
|
|
repo = _repository_module().TronbyteRepository()
|
|
repo.session.get = lambda *a, **k: _Resp(
|
|
raises=ValueError("Expecting value: line 1 column 1 (char 0)"))
|
|
|
|
assert repo._make_request("https://api.github.com/anything") is None
|
|
|
|
def test_it_says_the_response_was_not_json(self):
|
|
repo = _repository_module().TronbyteRepository()
|
|
repo.session.get = lambda *a, **k: _Resp(raises=ValueError("nope"))
|
|
|
|
repo._make_request("https://api.github.com/anything")
|
|
assert 'JSON' in (repo.last_error or ''), repo.last_error
|
|
|
|
|
|
class TestAnExhaustedRateLimitSaysSo:
|
|
"""60 requests/hour unauthenticated, shared with every update check."""
|
|
|
|
def test_the_message_names_the_rate_limit(self):
|
|
repo = _repository_module().TronbyteRepository()
|
|
repo.session.get = lambda *a, **k: _Resp(
|
|
status_code=403,
|
|
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
|
|
|
|
assert repo._make_request("https://api.github.com/anything") is None
|
|
assert 'rate limit' in (repo.last_error or '').lower(), repo.last_error
|
|
|
|
def test_it_mentions_being_unauthenticated_when_there_is_no_token(self):
|
|
repo = _repository_module().TronbyteRepository()
|
|
repo.session.get = lambda *a, **k: _Resp(
|
|
status_code=403,
|
|
headers={'X-RateLimit-Remaining': '0', 'X-RateLimit-Limit': '60'})
|
|
|
|
repo._make_request("https://api.github.com/anything")
|
|
assert 'unauthenticated' in (repo.last_error or ''), repo.last_error
|
|
|
|
def test_a_plain_403_is_not_reported_as_a_rate_limit(self):
|
|
repo = _repository_module().TronbyteRepository()
|
|
repo.session.get = lambda *a, **k: _Resp(
|
|
status_code=403, headers={'X-RateLimit-Remaining': '57'})
|
|
|
|
repo._make_request("https://api.github.com/anything")
|
|
assert 'rate limit' not in (repo.last_error or '').lower(), repo.last_error
|
|
|
|
|
|
class TestAFailedFetchIsNotAnEmptyRepository:
|
|
"""list_all_apps_cached turned every failure into an empty app list.
|
|
|
|
The route then reported that as a success, so a rate limit, a DNS failure
|
|
and a genuinely empty repository were all drawn as the same blank grid.
|
|
"""
|
|
|
|
def test_the_reason_comes_back_with_the_empty_list(self):
|
|
module = _repository_module()
|
|
repo = module.TronbyteRepository()
|
|
repo.list_apps = lambda: (False, None, "GitHub API rate limit exceeded")
|
|
|
|
result = repo.list_all_apps_cached()
|
|
assert result['count'] == 0
|
|
assert 'rate limit' in result['error'].lower(), result
|
|
|
|
def test_a_failure_is_not_cached_as_an_empty_repository(self):
|
|
module = _repository_module()
|
|
repo = module.TronbyteRepository()
|
|
repo.list_apps = lambda: (False, None, "boom")
|
|
repo.list_all_apps_cached()
|
|
|
|
assert module._apps_cache['data'] is None, \
|
|
"a failed fetch was cached, so the store stays empty for 2 hours"
|
|
|
|
def test_a_successful_fetch_reports_no_error(self):
|
|
module = _repository_module()
|
|
repo = module.TronbyteRepository()
|
|
repo.list_apps = lambda: (True, [{'id': 'a', 'path': 'apps/a'}], None)
|
|
repo._fetch_raw_file = lambda *a, **k: "name: A\nsummary: s\n"
|
|
|
|
assert repo.list_all_apps_cached().get('error') is None
|
|
|
|
|
|
class TestTheStoreReportsWhyItIsEmpty:
|
|
"""The route's half of the same failure."""
|
|
|
|
@pytest.fixture
|
|
def failing_repo(self):
|
|
repo = MagicMock()
|
|
repo.return_value.list_all_apps_cached.return_value = {
|
|
'apps': [], 'categories': [], 'authors': [], 'count': 0,
|
|
'cached': False,
|
|
'error': 'GitHub API rate limit exceeded (60 requests/hour, '
|
|
'unauthenticated).',
|
|
}
|
|
repo.return_value.get_rate_limit_info.return_value = {'remaining': 0}
|
|
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
return_value=repo):
|
|
yield repo
|
|
|
|
def test_browse_does_not_call_a_failure_a_success(self, client, failing_repo):
|
|
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
|
assert body['status'] == 'error', body
|
|
|
|
def test_browse_answers_502_not_200(self, client, failing_repo):
|
|
resp = client.get('/api/v3/starlark/repository/browse')
|
|
assert resp.status_code == 502, resp.get_json()
|
|
|
|
def test_the_reason_reaches_the_page(self, client, failing_repo):
|
|
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
|
assert 'rate limit' in body['message'].lower(), body
|
|
|
|
def test_categories_reports_it_too(self, client, failing_repo):
|
|
resp = client.get('/api/v3/starlark/repository/categories')
|
|
assert resp.status_code == 502
|
|
assert 'rate limit' in resp.get_json()['message'].lower()
|
|
|
|
@pytest.fixture
|
|
def working_repo(self):
|
|
repo = MagicMock()
|
|
repo.return_value.list_all_apps_cached.return_value = {
|
|
'apps': [{'id': 'quoteoftheday'}], 'categories': [], 'authors': [],
|
|
'count': 1, 'cached': False, 'error': None,
|
|
}
|
|
repo.return_value.get_rate_limit_info.return_value = {'remaining': 57}
|
|
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
return_value=repo):
|
|
yield repo
|
|
|
|
def test_a_working_fetch_is_still_a_success(self, client, working_repo):
|
|
resp = client.get('/api/v3/starlark/repository/browse')
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()['status'] == 'success'
|
|
|
|
|
|
class TestACrashCarriesItsDetail:
|
|
"""Seventeen Starlark handlers answered 5xx with no detail at all."""
|
|
|
|
def test_browse_returns_the_exception_detail(self, client):
|
|
with patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
side_effect=ImportError("No module named 'yaml'")):
|
|
body = client.get('/api/v3/starlark/repository/browse').get_json()
|
|
|
|
assert 'yaml' in body.get('details', ''), body
|
|
|
|
def test_status_returns_the_exception_detail(self, client):
|
|
with patch('web_interface.blueprints.api_v3._get_starlark_plugin',
|
|
side_effect=RuntimeError("plugin manager is not attached")):
|
|
body = client.get('/api/v3/starlark/status').get_json()
|
|
|
|
assert 'plugin manager is not attached' in body.get('details', ''), body
|
|
|
|
|
|
class TestTheListingIsNotCappedAtOneThousand:
|
|
"""The contents API caps a directory at 1000 entries and does not say so.
|
|
|
|
tronbyt/apps returns exactly 1000 through that endpoint, which is the cap
|
|
rather than the app count -- the store looked complete while showing a
|
|
truncated repository.
|
|
"""
|
|
|
|
def _repo_with_tree(self, module, count):
|
|
repo = module.TronbyteRepository()
|
|
entries = [{'path': 'app%04d' % i, 'type': 'tree'} for i in range(count)]
|
|
|
|
def fake_request(url, timeout=10):
|
|
if url.endswith('/git/trees/main'):
|
|
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'deadbeef'}]}
|
|
if url.endswith('/git/trees/deadbeef'):
|
|
return {'tree': entries, 'truncated': False}
|
|
raise AssertionError("unexpected request: %s" % url)
|
|
|
|
repo._make_request = fake_request
|
|
return repo
|
|
|
|
def test_more_than_a_thousand_apps_are_listed(self):
|
|
module = _repository_module()
|
|
repo = self._repo_with_tree(module, 1400)
|
|
|
|
ok, apps, err = repo.list_apps()
|
|
assert ok, err
|
|
assert len(apps) == 1400
|
|
|
|
def test_the_path_is_still_the_one_manifest_fetches_use(self):
|
|
module = _repository_module()
|
|
repo = self._repo_with_tree(module, 3)
|
|
|
|
_, apps, _ = repo.list_apps()
|
|
assert apps[0]['path'] == 'apps/app0000', apps[0]
|
|
|
|
def test_dotfiles_and_files_are_skipped(self):
|
|
module = _repository_module()
|
|
repo = module.TronbyteRepository()
|
|
|
|
def fake_request(url, timeout=10):
|
|
if url.endswith('/git/trees/main'):
|
|
return {'tree': [{'path': 'apps', 'type': 'tree', 'sha': 'x'}]}
|
|
return {'tree': [{'path': '.github', 'type': 'tree'},
|
|
{'path': 'realapp', 'type': 'tree'},
|
|
{'path': 'README.md', 'type': 'blob'}]}
|
|
|
|
repo._make_request = fake_request
|
|
_, apps, _ = repo.list_apps()
|
|
assert [a['id'] for a in apps] == ['realapp']
|
|
|
|
def test_it_falls_back_to_the_contents_api(self):
|
|
"""A trees outage must not take the store down with it."""
|
|
module = _repository_module()
|
|
repo = module.TronbyteRepository()
|
|
|
|
def fake_request(url, timeout=10):
|
|
if '/git/trees/' in url:
|
|
repo.last_error = "GitHub API error 500"
|
|
return None
|
|
return [{'name': 'fallbackapp', 'path': 'apps/fallbackapp', 'type': 'dir'}]
|
|
|
|
repo._make_request = fake_request
|
|
ok, apps, err = repo.list_apps()
|
|
assert ok, err
|
|
assert [a['id'] for a in apps] == ['fallbackapp']
|
|
|
|
def test_both_paths_failing_reports_the_reason(self):
|
|
module = _repository_module()
|
|
repo = module.TronbyteRepository()
|
|
|
|
def fake_request(url, timeout=10):
|
|
repo.last_error = "Timed out reaching GitHub"
|
|
return None
|
|
|
|
repo._make_request = fake_request
|
|
ok, apps, err = repo.list_apps()
|
|
assert not ok
|
|
assert 'Timed out' in err, err
|
|
|
|
|
|
class TestTheStoreUsesTheTokenTheUserConfigured:
|
|
"""The store authenticated with a key nothing ever writes.
|
|
|
|
The three repository routes read `github_token` off config.json. Nothing
|
|
writes that key: config.template.json has no such field, no setting
|
|
offers it, and the token the user actually configures goes to
|
|
config_secrets.json as `github.api_token`, which PluginStoreManager loads
|
|
and every other GitHub caller uses.
|
|
|
|
So the store ran unauthenticated at 60 requests/hour on the same per-IP
|
|
budget as 48 plugins' update checks, while the configured token sat
|
|
unused raising that same budget to 5000. On the device this was found on,
|
|
/plugins/store/github-status reported `authenticated: true` with a
|
|
rate_limit of 5000 while /starlark/repository/browse reported a limit of
|
|
60 -- the store going blank was that 60 running out.
|
|
"""
|
|
|
|
def test_the_store_managers_token_is_used(self):
|
|
from web_interface.blueprints import api_v3 as mod
|
|
|
|
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
MagicMock(github_token='ghp_configured')):
|
|
assert mod._starlark_github_token() == 'ghp_configured'
|
|
|
|
def test_a_hand_edited_config_key_still_works(self):
|
|
from web_interface.blueprints import api_v3 as mod
|
|
|
|
cfg = MagicMock()
|
|
cfg.load_config.return_value = {'github_token': 'ghp_by_hand'}
|
|
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
MagicMock(github_token=None)), \
|
|
patch.object(mod.api_v3, 'config_manager', cfg):
|
|
assert mod._starlark_github_token() == 'ghp_by_hand'
|
|
|
|
def test_no_token_anywhere_is_not_an_error(self):
|
|
from web_interface.blueprints import api_v3 as mod
|
|
|
|
cfg = MagicMock()
|
|
cfg.load_config.return_value = {}
|
|
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
MagicMock(github_token=None)), \
|
|
patch.object(mod.api_v3, 'config_manager', cfg):
|
|
assert mod._starlark_github_token() is None
|
|
|
|
def test_an_unreadable_config_does_not_take_the_store_down(self):
|
|
from web_interface.blueprints import api_v3 as mod
|
|
|
|
cfg = MagicMock()
|
|
cfg.load_config.side_effect = OSError("config.json is unreadable")
|
|
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
MagicMock(github_token=None)), \
|
|
patch.object(mod.api_v3, 'config_manager', cfg):
|
|
assert mod._starlark_github_token() is None
|
|
|
|
def test_browse_hands_the_token_to_the_repository(self, client):
|
|
from web_interface.blueprints import api_v3 as mod
|
|
|
|
repo = MagicMock()
|
|
repo.return_value.list_all_apps_cached.return_value = {
|
|
'apps': [], 'categories': [], 'authors': [], 'count': 0,
|
|
'cached': False, 'error': None,
|
|
}
|
|
repo.return_value.get_rate_limit_info.return_value = {'remaining': 4999}
|
|
|
|
with patch.object(mod.api_v3, 'plugin_store_manager',
|
|
MagicMock(github_token='ghp_configured')), \
|
|
patch('web_interface.blueprints.api_v3._get_tronbyte_repository_class',
|
|
return_value=repo):
|
|
client.get('/api/v3/starlark/repository/browse')
|
|
|
|
repo.assert_called_once_with(github_token='ghp_configured')
|