"""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/", "GET"), ("/api/v3/starlark/apps/", "DELETE"), ("/api/v3/starlark/apps//config", "GET"), ("/api/v3/starlark/apps//config", "PUT"), ("/api/v3/starlark/apps//toggle", "POST"), ("/api/v3/starlark/apps//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.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: 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.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.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 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:` 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'