mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 10:36:35 +00:00
Merge audit16-core/misccode into the September 16 core audit fixes
# Conflicts: # CHANGELOG.md
This commit is contained in:
+1
-1
@@ -35,7 +35,7 @@ nothing is listening, so it stays useful in a bare checkout.
|
||||
| Suite | Needs a server | Covers |
|
||||
|---|---|---|
|
||||
| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it |
|
||||
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), and re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
||||
| `unit/test_update_all.js` | no | `PluginInstallManager.updateAll` from `plugins/install_manager.js`: Check & Update All sends only plugin ids (never `starlark:` app entries), re-sends a request that got no HTTP answer (web service restarting) instead of skipping that plugin, never re-sends one that got any HTTP answer (the real `api_client.js` classifies a proxy 502 or a JSON error without `error_code` as `API_ERROR`), and counts a no-op update as already up to date in the summary. Also run by `test/web_interface/test_update_all_plugins.py` so CI covers it |
|
||||
| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata |
|
||||
| `unit/test_style_editor_element_keys.js` | no | `elementKeys()`/`styleRows()`/`positionRows()` from `widgets/style-editor.js`: every `customization.layout` entry gets exactly one row -- paired with its style element through core's `x-layout-key` (so `score` belongs to `score_text`, not a second row), or a position row of its own, leaves included -- since the widget claims the whole `layout` block from the generic fallback renderer |
|
||||
| `unit/test_style_editor_layout_leaf_columns.js` | no | `columnsFor()` from `widgets/style-editor.js`: a layout-only key whose own value is a leaf (no x/y sub-object, e.g. a `show_logo` toggle) gets a self-keyed column instead of a blank, uneditable row |
|
||||
|
||||
@@ -146,6 +146,73 @@ const noSleep = { sleep: async () => {} };
|
||||
ok('...and is reported as a failure', results.find(x => x.pluginId === 'static-image').success === false);
|
||||
}
|
||||
|
||||
console.log('\nthe real api_client.js: only a missing HTTP answer is retried');
|
||||
{
|
||||
// The fake PluginAPI above decides error_code itself. This runs the shipped
|
||||
// client so its classification is what gets tested: a proxy's 502 page or
|
||||
// a JSON 500 without error_code used to come back as NETWORK_ERROR and be
|
||||
// re-sent five more times.
|
||||
const PluginAPI = require(path.join(V3, 'js/plugins/api_client.js'));
|
||||
const run = async (makeFetch) => {
|
||||
let requests = 0, sleeps = 0;
|
||||
global.fetch = async () => { requests++; return makeFetch(requests); };
|
||||
global.window = { PluginAPI, installedPlugins: [{ id: 'stock-news' }] };
|
||||
const results = await Manager.updateAll(null, { sleep: async () => { sleeps++; }, retryDelaysMs: [1, 1, 1] });
|
||||
return { requests, sleeps, result: results[0] };
|
||||
};
|
||||
const httpAnswer = (status, json) => ({ ok: status < 400, status, json });
|
||||
|
||||
let r = await run(() => httpAnswer(502, async () => { throw new SyntaxError('Unexpected token <'); }));
|
||||
ok('a 502 with an HTML body is sent once', r.requests === 1 && r.sleeps === 0, r);
|
||||
ok('...and is an API_ERROR carrying the status, not NETWORK_ERROR',
|
||||
r.result.success === false && r.result.error.error_code === 'API_ERROR' && r.result.error.status === 502, r.result.error);
|
||||
|
||||
r = await run(() => httpAnswer(500, async () => ({ status: 'error', message: 'boom' })));
|
||||
ok('a JSON 500 without error_code is sent once', r.requests === 1 && r.sleeps === 0, r);
|
||||
ok('...and keeps the server message', r.result.error.message === 'boom', r.result.error);
|
||||
|
||||
r = await run(() => httpAnswer(500, async () => ({ status: 'error', error_code: 'PLUGIN_UPDATE_FAILED', message: 'x' })));
|
||||
ok('a structured error is passed through unchanged',
|
||||
r.requests === 1 && r.result.error.error_code === 'PLUGIN_UPDATE_FAILED', r.result.error);
|
||||
|
||||
r = await run((n) => {
|
||||
if (n === 1) throw new TypeError('Failed to fetch');
|
||||
return httpAnswer(200, async () => ({ status: 'success', message: 'ok', data: { update_status: 'updated' } }));
|
||||
});
|
||||
ok('a fetch() that rejects is NETWORK_ERROR and re-sent', r.requests === 2 && r.sleeps === 1 && r.result.success, r);
|
||||
|
||||
r = await run(() => httpAnswer(200, async () => { throw new SyntaxError('Unexpected end of JSON input'); }));
|
||||
ok('an unreadable 200 is not retried either', r.requests === 1 && r.result.error.error_code === 'API_ERROR', r);
|
||||
delete global.fetch;
|
||||
}
|
||||
|
||||
console.log('\nsummary: a no-op update is not counted as updated');
|
||||
{
|
||||
const answer = (update_status, message) => ({ success: true, result: { status: 'success', message, data: { update_status } } });
|
||||
const results = [
|
||||
answer('updated', 'Plugin a updated to version 2.8.0'),
|
||||
// ZIP-installed monorepo plugin already at the registry version: the
|
||||
// route used to call this "updated successfully".
|
||||
answer('up_to_date', 'Plugin stock-news already up to date (version 2.8.0)'),
|
||||
answer('up_to_date', 'Plugin clock already up to date (commit abcdef1)'),
|
||||
answer('local_only', 'Plugin mine is managed locally and does not receive registry updates'),
|
||||
{ success: false, error: { error_code: 'PLUGIN_UPDATE_FAILED' } },
|
||||
];
|
||||
const s = Manager.summarizeUpdateResults(results);
|
||||
ok('counts come from update_status',
|
||||
s.updated === 1 && s.upToDate === 2 && s.localOnly === 1 && s.failed === 1, s);
|
||||
ok('toast text names each outcome',
|
||||
s.text === '1 updated, 2 already up to date, 1 managed locally, 1 failed', s.text);
|
||||
ok('a failure alongside an update is a warning', s.type === 'warning', s.type);
|
||||
ok('an older server that only says so in the message is still up to date',
|
||||
Manager.updateOutcome({ success: true, result: { message: 'Plugin x already up to date (commit 1234567)' } }) === 'up_to_date');
|
||||
ok('a success without a status or telltale message counts as updated',
|
||||
Manager.updateOutcome({ success: true, result: { message: 'Plugin x updated successfully' } }) === 'updated');
|
||||
const allNoop = Manager.summarizeUpdateResults([answer('up_to_date', ''), answer('up_to_date', '')]);
|
||||
ok('nothing to do is a success toast with no "updated"',
|
||||
allNoop.type === 'success' && allNoop.text === '2 already up to date', allNoop);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -13,7 +13,12 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from src.background_data_service import BackgroundDataService
|
||||
from src.common.espn_dates import ESPN_MAX_LIMIT, parse_espn_date_range
|
||||
from src.common import espn_dates
|
||||
from src.common.espn_dates import (
|
||||
ESPN_MAX_LIMIT,
|
||||
fetch_espn_scoreboard,
|
||||
parse_espn_date_range,
|
||||
)
|
||||
|
||||
URL = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
||||
|
||||
@@ -50,6 +55,12 @@ class RangeRejectingSession:
|
||||
return FakeResponse(200, {"events": self.events_by_chunk.get(dates, [])})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def ranges_not_yet_rejected(monkeypatch):
|
||||
# The "ranges are rejected" memo is process-wide; every test starts clean.
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", 0.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache():
|
||||
manager = MagicMock()
|
||||
@@ -161,3 +172,42 @@ def test_a_400_on_a_single_day_is_still_a_failure(service, cache):
|
||||
assert not result.success
|
||||
assert len(session.calls) == 1
|
||||
cache.set.assert_not_called()
|
||||
|
||||
|
||||
def test_a_rejection_seen_by_the_service_is_remembered_for_scoreboards(service):
|
||||
submit_and_wait(service, RangeRejectingSession({"202609": [{"id": "a"}]}),
|
||||
"20260901-20260930")
|
||||
|
||||
# A live scoreboard asking for a range next must not spend a doomed 400.
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
data = fetch_espn_scoreboard(session, URL, params={"dates": "20260901-20260930"})
|
||||
assert [call["dates"] for call in session.calls] == ["202609"]
|
||||
assert [event["id"] for event in data["events"]] == ["a"]
|
||||
|
||||
|
||||
def test_a_rejection_seen_by_a_scoreboard_skips_the_range_in_the_service(service, cache):
|
||||
fetch_espn_scoreboard(RangeRejectingSession(), URL,
|
||||
params={"dates": "20260901-20260930"})
|
||||
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
result = submit_and_wait(service, session, "20260901-20261001")
|
||||
assert result.success, result.error
|
||||
assert [call["dates"] for call in session.calls] == ["202609", "20261001"]
|
||||
|
||||
|
||||
def test_known_rejection_with_every_chunk_failing_asks_the_range_once(service, cache):
|
||||
espn_dates._note_range_rejected()
|
||||
session = RangeRejectingSession(fail_chunks={"202609"})
|
||||
result = submit_and_wait(service, session, "20260901-20260930")
|
||||
|
||||
assert not result.success
|
||||
# Chunks once, then the range for a real error -- not the chunks again.
|
||||
assert [call["dates"] for call in session.calls] == ["202609", "20260901-20260930"]
|
||||
cache.set.assert_not_called()
|
||||
|
||||
|
||||
def test_ranges_are_tried_again_once_the_memo_expires(service, monkeypatch):
|
||||
monkeypatch.setattr(espn_dates, "_ranges_rejected_until", time.monotonic() - 1)
|
||||
session = RangeRejectingSession({"202609": [{"id": "a"}]})
|
||||
submit_and_wait(service, session, "20260901-20260930")
|
||||
assert [call["dates"] for call in session.calls] == ["20260901-20260930", "202609"]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""plugin_system.auto_discover / auto_load_enabled / development_mode.
|
||||
|
||||
The General tab offered three toggles for these, with help tips promising
|
||||
"plugins installed but dormant" and "verbose logging". Nothing in src/,
|
||||
web_interface/ or scripts/ reads them: every enabled plugin is discovered and
|
||||
loaded regardless. The toggles are gone; the keys stay tolerated in stored
|
||||
configs, and saving the General tab must not rewrite them.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
||||
from test.test_web_settings_ui import REALISTIC_CONFIG, client # noqa: F401,E402
|
||||
|
||||
LEGACY_FLAGS = ('auto_discover', 'auto_load_enabled', 'development_mode')
|
||||
|
||||
|
||||
def test_the_general_tab_no_longer_offers_the_toggles(client):
|
||||
body = client.get('/v3/partials/general').get_data(as_text=True)
|
||||
for flag in LEGACY_FLAGS:
|
||||
assert f'name="{flag}"' not in body, flag
|
||||
assert f'setting-general-{flag}' not in body, flag
|
||||
# The setting that does work stays.
|
||||
assert 'name="plugins_directory"' in body
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saved(api_v3_module, monkeypatch):
|
||||
captured = {}
|
||||
stored = copy.deepcopy(REALISTIC_CONFIG)
|
||||
stored['plugin_system'].update(
|
||||
auto_discover=True, auto_load_enabled=True, development_mode=True)
|
||||
api_v3_module.api_v3.config_manager.load_config.return_value = stored
|
||||
|
||||
def fake_save(_manager, config, **_kwargs):
|
||||
captured['config'] = config
|
||||
return True, ''
|
||||
|
||||
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
|
||||
return captured
|
||||
|
||||
|
||||
def test_saving_the_general_form_leaves_stored_flags_alone(api_v3_client, saved):
|
||||
# What the General form posts now: no checkbox fields for the flags.
|
||||
resp = api_v3_client.post('/api/v3/config/main', data={
|
||||
'timezone': 'America/Chicago', 'city': 'Dallas', 'state': 'Texas',
|
||||
'country': 'US', 'plugins_directory': 'plugin-repos',
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
plugin_system = saved['config']['plugin_system']
|
||||
# Missing used to mean "unchecked" and saved all three as false.
|
||||
assert all(plugin_system[flag] is True for flag in LEGACY_FLAGS), plugin_system
|
||||
assert plugin_system['plugins_directory'] == 'plugin-repos'
|
||||
|
||||
|
||||
def test_an_api_client_can_still_store_a_flag(api_v3_client, saved):
|
||||
resp = api_v3_client.post('/api/v3/config/main', data=json.dumps({
|
||||
'timezone': 'America/Chicago', 'development_mode': False,
|
||||
}), content_type='application/json')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
plugin_system = saved['config']['plugin_system']
|
||||
assert plugin_system['development_mode'] is False
|
||||
assert plugin_system['auto_discover'] is True
|
||||
@@ -80,6 +80,67 @@ class TestUpdateRouteRejectsStarlarkIds:
|
||||
assert [c.args[0] for c in store.update_plugin.call_args_list] == ['stock-news', 'starlark-apps']
|
||||
|
||||
|
||||
class TestUpdateRouteReportsNoOps:
|
||||
"""update_plugin() answers True when there was nothing to do.
|
||||
|
||||
A ZIP-installed monorepo plugin (no .git) already at the registry version
|
||||
is the common case for official plugins. The route used to call that
|
||||
"updated successfully", so Check & Update All counted it as updated.
|
||||
"""
|
||||
|
||||
def _install(self, tmp_path, pid, version, last_updated='2026-09-01'):
|
||||
(tmp_path / pid).mkdir()
|
||||
(tmp_path / pid / 'manifest.json').write_text(
|
||||
'{"id": "%s", "version": "%s", "last_updated": "%s"}' % (pid, version, last_updated),
|
||||
encoding='utf-8')
|
||||
|
||||
def test_a_zip_plugin_already_at_the_registry_version_is_up_to_date(self, client, store, tmp_path):
|
||||
self._install(tmp_path, 'stock-news', '2.8.0')
|
||||
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
|
||||
assert body['status'] == 'success'
|
||||
assert body['data']['update_status'] == 'up_to_date'
|
||||
assert 'already up to date' in body['message'], body
|
||||
assert 'updated successfully' not in body['message']
|
||||
|
||||
def test_a_zip_plugin_reinstalled_at_a_new_version_is_updated(self, client, store, tmp_path):
|
||||
self._install(tmp_path, 'stock-news', '2.6.2')
|
||||
|
||||
def reinstall(pid):
|
||||
(tmp_path / pid / 'manifest.json').write_text(
|
||||
'{"id": "%s", "version": "2.8.0", "last_updated": "2026-09-01"}' % pid,
|
||||
encoding='utf-8')
|
||||
return True
|
||||
store.update_plugin.side_effect = reinstall
|
||||
|
||||
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'stock-news'}).get_json()
|
||||
assert body['data']['update_status'] == 'updated'
|
||||
assert '2.8.0' in body['message'], body
|
||||
|
||||
def test_a_git_plugin_whose_commit_is_unchanged_is_up_to_date(self, client, store, tmp_path):
|
||||
self._install(tmp_path, 'clock', '1.0.0')
|
||||
store._get_local_git_info.return_value = {'sha': 'abcdef1234567', 'branch': 'main'}
|
||||
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
|
||||
assert body['data']['update_status'] == 'up_to_date'
|
||||
|
||||
def test_a_git_plugin_that_moved_is_updated(self, client, store, tmp_path):
|
||||
self._install(tmp_path, 'clock', '1.0.0')
|
||||
store._get_local_git_info.side_effect = [
|
||||
{'sha': 'aaaaaaa000', 'branch': 'main'}, # before
|
||||
{'sha': 'aaaaaaa000', 'branch': 'main'}, # is-git check
|
||||
{'sha': 'bbbbbbb111', 'branch': 'main'}, # after
|
||||
]
|
||||
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'clock'}).get_json()
|
||||
assert body['data']['update_status'] == 'updated', body
|
||||
|
||||
def test_a_local_only_plugin_says_so(self, client, store, tmp_path):
|
||||
(tmp_path / 'mine').mkdir()
|
||||
(tmp_path / 'mine' / 'manifest.json').write_text(
|
||||
'{"id": "mine", "version": "0.1.0", "local_only": true}', encoding='utf-8')
|
||||
body = client.post('/api/v3/plugins/update', json={'plugin_id': 'mine'}).get_json()
|
||||
assert body['data']['update_status'] == 'local_only'
|
||||
store.update_plugin.assert_not_called()
|
||||
|
||||
|
||||
class TestInstalledListContract:
|
||||
"""What update-all filters on is what /plugins/installed publishes."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user