Gate the USB kiosk the same way, and make an unreachable setting a test failure
The USB kiosk had a feature flag and nothing else. A flag is a decision, not a credential: with the kiosk on, anyone who could reach the app could move a drive between checked-out and checked-in and sign it with a badge they guessed. Same reasoning that closed the parts kiosk, smaller blast radius - which is why Darkwing did not raise it and why it was worth asking about anyway. Both kiosks now share one mechanism. The device token moved out of the printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token by URL prefix, with one localStorage key per kiosk - they are separate screens in separate rooms, and a shared token would mean provisioning one grants the other. claimKioskToken() is shared too, so both views take the token from a ?token= URL once and strip it from the address bar identically. Then the part that matters more than either fix. I told you to set the new settings in the UI, and that was impossible: every plugin settings page carries a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled, printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read correctly, and rendered by no page - changeable only by writing SQL. tests/test_plugin_settings_reachable.py now fails when any declared setting appears nowhere in its plugin's frontend. It found seven MORE that were already in that state, all pre-existing: collector_corporate_ranges computers_configpolicies_enabled computers_machinelink_alerts computers_quietreporthours geenforce_reportstaleminutes printers_dashboardpercent warranty_expiringdays All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then fixed them and deleted the ratchet - a backlog list in a new check is a way of agreeing the check does not apply yet. There is no backlog; the check is absolute. Two of my own mistakes on the way, both caught by tests rather than by reading: the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so it called itself until the stack ran out; and the first version of the reachable check skipped all 17 plugins because it looked for get_settings() when the method is get_settings_defaults() - it passed by testing nothing, which is the exact shape it exists to catch. Suite 2336, frontend 316, build clean. Scope: product
This commit is contained in:
88
tests/test_plugin_settings_reachable.py
Normal file
88
tests/test_plugin_settings_reachable.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Every setting a plugin declares must be reachable in its settings page.
|
||||
|
||||
A setting exists so a site can change it. One that is seeded into the database
|
||||
but rendered by no page is not configurable - it is a constant with extra steps,
|
||||
and the only way to change it is direct SQL.
|
||||
|
||||
This was written after shipping exactly that twice in one afternoon:
|
||||
printedparts_kiosk_enabled / _kiosk_token and then usb_kiosk_token were declared
|
||||
in get_settings(), seeded correctly, and read correctly by the API - and none of
|
||||
them appeared on any page, because each settings view carries a hardcoded KEYS
|
||||
list that nobody remembered to extend. The instructions for turning the kiosk
|
||||
back on were impossible to follow.
|
||||
|
||||
The check is deliberately crude: it greps the plugin's frontend for the key as a
|
||||
literal. That is enough to catch the failure it exists for, and it cannot be
|
||||
satisfied by accident.
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[1]
|
||||
PLUGINS = REPO / 'plugins'
|
||||
|
||||
# Settings a site is not meant to edit through a page. Each needs a reason.
|
||||
EXEMPT = {
|
||||
# Written by the app itself when the agent key is minted, not typed.
|
||||
'plugin.endpoints.agentkeyprovisioned',
|
||||
'plugin.endpoints.envkeyfallback',
|
||||
}
|
||||
|
||||
|
||||
|
||||
def _declared_settings(plugin_dir):
|
||||
"""Keys the plugin declares in get_settings_defaults(), read from the source.
|
||||
|
||||
Importing the plugin would need an app context and a database; the
|
||||
declaration is a literal list, so reading it is enough and much cheaper.
|
||||
"""
|
||||
source = (plugin_dir / 'plugin.py')
|
||||
if not source.is_file():
|
||||
return []
|
||||
text = source.read_text(encoding='utf-8')
|
||||
match = re.search(r'def get_settings_defaults\(self\).*?(?=\n def |\Z)', text, re.S)
|
||||
if not match:
|
||||
return []
|
||||
return re.findall(r"'key':\s*'([^']+)'", match.group(0))
|
||||
|
||||
|
||||
def _frontend_text(plugin_dir):
|
||||
frontend = plugin_dir / 'frontend'
|
||||
if not frontend.is_dir():
|
||||
return ''
|
||||
parts = []
|
||||
for path in frontend.rglob('*'):
|
||||
if path.suffix in ('.vue', '.js') and path.is_file():
|
||||
parts.append(path.read_text(encoding='utf-8'))
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
PLUGIN_DIRS = sorted(
|
||||
d for d in PLUGINS.iterdir()
|
||||
if d.is_dir() and (d / 'manifest.json').is_file()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin_dir', PLUGIN_DIRS, ids=lambda d: d.name)
|
||||
def test_every_declared_setting_is_reachable_in_the_ui(plugin_dir):
|
||||
declared = [k for k in _declared_settings(plugin_dir) if k not in EXEMPT]
|
||||
if not declared:
|
||||
pytest.skip('declares no settings')
|
||||
|
||||
frontend = _frontend_text(plugin_dir)
|
||||
if not frontend:
|
||||
pytest.skip('ships no frontend')
|
||||
|
||||
missing = [key for key in declared if key not in frontend]
|
||||
assert not missing, (
|
||||
'%s declares %d setting(s) that appear nowhere in its frontend, so a '
|
||||
'site cannot change them without writing SQL:\n %s\n'
|
||||
'Add them to the plugin settings view (its KEYS list AND a field), or '
|
||||
'to EXEMPT in this file with a reason. There is no backlog list: the '
|
||||
'seven this check found when it was written were all fixed, and it '
|
||||
'stays that way.'
|
||||
% (plugin_dir.name, len(missing), '\n '.join(missing)))
|
||||
Reference in New Issue
Block a user