Let a site close the legacy collector routes, once it can afford to
Turning "Shared key allowed" off refused the key on the endpoints plugin and left four core collector routes open to it. Anything still holding the old key could keep rewriting logged-in user, last boot time, serial number and installed app inventory for ANY hostname, while the admin believed the shared key was off. Data integrity only - no command execution, no credential issuance - but it is a hole in a boundary the product advertises as closed. It cannot simply be closed. Both GE-Enforce reporters still authenticate with that key, so refusing it fleet-wide would stop asset and printer reporting everywhere, and on an air-gapped site mid-migration that is the wrong order of operations. collector.legacysharedkeyrefused therefore DEFAULTS TO TODAY'S BEHAVIOUR, and its description names exactly what turning it on breaks and when it should be turned on: after the reporters move to per-PC credentials, not before. A legacy route used with the shared key now logs a warning, so a site can find out who still depends on the key before flipping anything. On a stock Windows deployment that warning may name the proxy rather than the caller; the token prefix in the same line is the identifier to go by. The switch is seeded with the core defaults and has a control on the authentication settings page. Undeclared, it was a row that did not exist, and this UI has no generic key/value editor, so seeding alone would have rendered nothing for an operator to click. Two contract declarations corrected while in here: notifications.alertscope was declared db.JSON in the model while the migration that creates it ships TEXT, and those migrations state the constraint in their own docstrings: no JSON columns. The model now matches the shipped column rather than the schema being bent to the model. usb and printedparts both import a name that arrived in contract 0.35.0 while declaring core_version floors of 0.1.0 and 0.16.0, so the loader would have accepted them on a core that cannot satisfy them - an ImportError at blueprint registration instead of a refusal with a reason. Both floors now say what the plugin actually needs.
This commit is contained in:
77
tests/test_plugins/test_manifest_core_version_floor.py
Normal file
77
tests/test_plugins/test_manifest_core_version_floor.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""usb and printedparts declare a core_version floor they actually need.
|
||||
|
||||
Both reach for the network-position allowlist that arrived on the contract
|
||||
surface in 0.35.0 (commit "Admit kiosk callers by network position"): ip_in_cidrs
|
||||
and request_ip_allowlisted landed together in that bump, so either one witnesses
|
||||
the same floor. Their manifests claimed >=0.1.0 and >=0.16.0, so the loader would
|
||||
have accepted them on a core whose shopdb.api has no such name - the plugin then
|
||||
fails at first import of its routes instead of at the version check that exists
|
||||
to catch exactly this.
|
||||
|
||||
The witness is named PER PLUGIN and not shared. usb needs the matcher itself,
|
||||
because its admission is the allowlist minus the site's proxies and the
|
||||
addresses no caller can have, which the one-call helper does not express;
|
||||
printedparts is the plain case and uses ip_in_cidrs. A single shared
|
||||
name went red the moment usb moved, which said "the floor is wrong" when the
|
||||
floor was right.
|
||||
"""
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version
|
||||
|
||||
from shopdb import __contract_version__
|
||||
|
||||
PLUGINS = pathlib.Path(__file__).resolve().parents[2] / 'plugins'
|
||||
|
||||
# The contract version that added request_ip_allowlisted / ip_in_cidrs.
|
||||
IP_ALLOWLIST_CONTRACT = '0.35.0'
|
||||
|
||||
# plugin, the 0.35.0 name it really imports, the floor that name implies
|
||||
CASES = [
|
||||
('usb', 'ip_in_cidrs', IP_ALLOWLIST_CONTRACT),
|
||||
# Was request_ip_allowlisted until 2026-09-18, when the parts kiosk moved to
|
||||
# client_ip + ip_in_cidrs so it could SUBTRACT proxy and loopback addresses
|
||||
# before consulting the allowlist. Same contract release, same floor.
|
||||
('printedparts', 'ip_in_cidrs', IP_ALLOWLIST_CONTRACT),
|
||||
]
|
||||
|
||||
|
||||
def _manifest(name):
|
||||
return json.loads((PLUGINS / name / 'manifest.json').read_text())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_manifest_excludes_a_core_without_the_name(name, witness, needed):
|
||||
"""A core one minor below the name's arrival must be refused."""
|
||||
spec = SpecifierSet(_manifest(name)['core_version'])
|
||||
too_old = Version(needed)
|
||||
too_old = Version(f'{too_old.major}.{too_old.minor - 1}.0')
|
||||
assert too_old not in spec
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_manifest_admits_the_running_core(name, witness, needed):
|
||||
"""And the floor must not have overshot what this core provides."""
|
||||
spec = SpecifierSet(_manifest(name)['core_version'])
|
||||
assert Version(__contract_version__) in spec
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_plugin_really_imports_the_name(name, witness, needed):
|
||||
"""Pins the floor to the reason for it: drop the import and this test says
|
||||
the floor can come back down."""
|
||||
sources = (PLUGINS / name).rglob('*.py')
|
||||
assert any(witness in path.read_text() for path in sources), name
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,witness,needed', CASES)
|
||||
def test_the_name_is_on_the_contract_surface(name, witness, needed):
|
||||
"""Each witness is a contract name, not an internal import a plugin reached
|
||||
through - which is the only reason the manifest floor can speak for it."""
|
||||
import shopdb.api
|
||||
|
||||
assert witness in shopdb.api.__all__
|
||||
93
tests/test_plugins/test_notifications_alertscope_storage.py
Normal file
93
tests/test_plugins/test_notifications_alertscope_storage.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""alertscope is TEXT in the database, a dict in Python.
|
||||
|
||||
Migration 0006 ships the column as TEXT because a site still runs MySQL 5.6,
|
||||
which has no JSON type. The model declared db.JSON, so create-all built a column
|
||||
the migration path never produces and the two disagreed about the same column.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from sqlalchemy import Text, inspect
|
||||
|
||||
from plugins.notifications.models.notification import Notification
|
||||
|
||||
|
||||
def test_column_type_matches_the_migration(app):
|
||||
"""The model's storage type must be the one migration 0006 creates."""
|
||||
column = inspect(Notification).columns['alertscope']
|
||||
assert isinstance(column.type.impl_instance
|
||||
if hasattr(column.type, 'impl_instance')
|
||||
else column.type.impl, Text)
|
||||
|
||||
|
||||
def test_ddl_has_no_json_column(app, db):
|
||||
"""MySQL 5.6 fails on a JSON column with a bare syntax error, so the
|
||||
emitted DDL must not contain one."""
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
ddl = str(CreateTable(Notification.__table__).compile(db.engine))
|
||||
alertscope_line = [line for line in ddl.splitlines()
|
||||
if 'alertscope' in line][0]
|
||||
assert 'JSON' not in alertscope_line.upper()
|
||||
|
||||
|
||||
def test_dict_round_trips(app, db):
|
||||
"""A scope assigned as a dict reads back as a dict."""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0,
|
||||
alertscope={'hostname': ['BAY-01', 'BAY-02']})
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
notificationid = row.notificationid
|
||||
db.session.expire_all()
|
||||
|
||||
reread = db.session.get(Notification, notificationid)
|
||||
assert reread.alertscope == {'hostname': ['BAY-01', 'BAY-02']}
|
||||
|
||||
|
||||
def test_stored_value_is_json_text(app, db):
|
||||
"""What lands in the column is a JSON string, readable by a site that
|
||||
queries the table directly."""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0, alertscope={'computertype': 'CMM PC'})
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
|
||||
raw = db.session.execute(
|
||||
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
|
||||
{'i': row.notificationid}).scalar()
|
||||
assert isinstance(raw, str)
|
||||
assert json.loads(raw) == {'computertype': 'CMM PC'}
|
||||
|
||||
|
||||
def test_null_scope_stays_null(app, db):
|
||||
"""No scope means the whole fleet; it must not become the string 'null'."""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0, alertscope=None)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
|
||||
raw = db.session.execute(
|
||||
db.text('SELECT alertscope FROM notifications WHERE notificationid = :i'),
|
||||
{'i': row.notificationid}).scalar()
|
||||
assert raw is None
|
||||
assert row.alertscope is None
|
||||
|
||||
|
||||
def test_unparseable_text_is_handed_back_raw(app, db):
|
||||
"""A hand-edited row must be visible and fixable, not an exception on read.
|
||||
The endpoints matcher already treats a non-dict scope as matching nothing.
|
||||
"""
|
||||
row = Notification(notification='fleet alert', alertfleet=True,
|
||||
alerttimeout=0)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
db.session.execute(
|
||||
db.text('UPDATE notifications SET alertscope = :v '
|
||||
'WHERE notificationid = :i'),
|
||||
{'v': 'not json at all', 'i': row.notificationid})
|
||||
db.session.commit()
|
||||
db.session.expire_all()
|
||||
|
||||
reread = db.session.get(Notification, row.notificationid)
|
||||
assert reread.alertscope == 'not json at all'
|
||||
Reference in New Issue
Block a user