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:
143
tests/test_core/test_collector_legacy_sharedkey.py
Normal file
143
tests/test_core/test_collector_legacy_sharedkey.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""The legacy core collector routes and the shared key.
|
||||
|
||||
/pc, /apps, /heartbeat and /bulk name their target PC in the request body, so
|
||||
the shared collector key is an any-host write key on them and no per-PC
|
||||
credential narrows it. collector.legacysharedkeyrefused closes them to the
|
||||
shared key. It defaults OFF so nothing on an un-migrated fleet changes, and the
|
||||
shared key is logged either way so a site can see what depends on it first.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.api.collector import (
|
||||
LEGACY_SHARED_KEY_SETTING,
|
||||
LEGACY_SHARED_KEY_SETTING_DEFAULT,
|
||||
)
|
||||
|
||||
KEY = 'testlegacykey'
|
||||
|
||||
LEGACY_POSTS = [
|
||||
('/api/collector/pc', {'hostname': 'WJLEG01'}),
|
||||
('/api/collector/apps', {'hostname': 'WJLEG01',
|
||||
'apps': [{'appname': 'Nothing'}]}),
|
||||
('/api/collector/heartbeat', {'hostname': 'WJLEG01'}),
|
||||
('/api/collector/bulk', {'pcs': [{'hostname': 'WJLEG01'}]}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_key(app):
|
||||
old = app.config.get('COLLECTOR_API_KEY')
|
||||
app.config['COLLECTOR_API_KEY'] = KEY
|
||||
yield KEY
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_closed(db):
|
||||
"""Turn the closure on for the duration of a test."""
|
||||
from shopdb.core.models.setting import Setting
|
||||
|
||||
Setting.set(LEGACY_SHARED_KEY_SETTING, True, valuetype='boolean',
|
||||
category='collector')
|
||||
yield
|
||||
Setting.query.filter_by(key=LEGACY_SHARED_KEY_SETTING).delete()
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _mint_collector_token(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'collector svc',
|
||||
'scopes': ['collector.ingest']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path,payload', LEGACY_POSTS)
|
||||
def test_shared_key_still_works_by_default(client, db, collector_key,
|
||||
path, payload):
|
||||
"""No setting row: today's behaviour. The routes answer the shared key.
|
||||
|
||||
404/400 is fine - the point is that authorization passed, not that the
|
||||
hostname exists.
|
||||
"""
|
||||
resp = client.post(path, json=payload, headers={'X-API-Key': KEY})
|
||||
assert resp.status_code != 401, resp.get_json()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path,payload', LEGACY_POSTS)
|
||||
def test_shared_key_refused_when_closed(client, db, collector_key,
|
||||
legacy_closed, path, payload):
|
||||
"""Setting on: the shared key is refused the way the endpoints plugin
|
||||
refuses it, before any write happens."""
|
||||
resp = client.post(path, json=payload, headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 401
|
||||
assert resp.get_json()['data']['error']['code'] == 'SHARED_KEY_REFUSED'
|
||||
|
||||
|
||||
def test_managed_token_unaffected_by_closure(client, db, auth_headers,
|
||||
legacy_closed):
|
||||
"""The closure targets the shared key only. A collector-scoped managed
|
||||
token keeps working on a legacy route."""
|
||||
secret = _mint_collector_token(client, auth_headers)
|
||||
resp = client.post('/api/collector/heartbeat',
|
||||
json={'hostname': 'WJLEG01'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
|
||||
def test_status_stays_open_when_closed(client, db, collector_key,
|
||||
legacy_closed):
|
||||
"""/status writes nothing, so the closure does not touch it - it stays a
|
||||
usable connectivity check for a fleet still on the shared key."""
|
||||
resp = client.get('/api/collector/status', headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_generic_collector_unaffected_by_closure(client, db, auth_headers,
|
||||
collector_key, legacy_closed):
|
||||
"""The generic per-plugin route has its own shared-key rules (per-PC
|
||||
credentials). This setting must not reach it."""
|
||||
from shopdb.core.models import AssetType
|
||||
|
||||
db.session.add(AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers', description='PCs'))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={'hostname': 'WJLEG02'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
|
||||
def test_shared_key_use_is_logged(client, db, collector_key, caplog):
|
||||
"""The warning is what a site reads before flipping the setting, so it has
|
||||
to be emitted while the routes are still open."""
|
||||
with caplog.at_level('WARNING'):
|
||||
client.post('/api/collector/heartbeat', json={'hostname': 'WJLEG01'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert any('Legacy collector route used with the shared key' in r.message
|
||||
for r in caplog.records)
|
||||
|
||||
|
||||
def test_managed_token_use_is_not_logged(client, db, auth_headers, caplog):
|
||||
"""A managed token is the supported credential; logging it would bury the
|
||||
shared-key warnings the site is looking for."""
|
||||
secret = _mint_collector_token(client, auth_headers)
|
||||
with caplog.at_level('WARNING'):
|
||||
client.post('/api/collector/heartbeat', json={'hostname': 'WJLEG01'},
|
||||
headers={'X-API-Key': secret})
|
||||
assert not any('used with the shared key' in r.message
|
||||
for r in caplog.records)
|
||||
|
||||
|
||||
def test_setting_default_is_todays_behaviour():
|
||||
"""The declared default must leave the routes open: a site mid-migration
|
||||
has un-enrolled PCs and the shared key is all they have."""
|
||||
assert LEGACY_SHARED_KEY_SETTING_DEFAULT['key'] == LEGACY_SHARED_KEY_SETTING
|
||||
assert LEGACY_SHARED_KEY_SETTING_DEFAULT['value'] == 'false'
|
||||
assert LEGACY_SHARED_KEY_SETTING_DEFAULT['valuetype'] == 'boolean'
|
||||
description = LEGACY_SHARED_KEY_SETTING_DEFAULT['description']
|
||||
for route in ('/pc', '/apps', '/heartbeat', '/bulk'):
|
||||
assert route in description
|
||||
228
tests/test_core/test_settings_switches_reachable.py
Normal file
228
tests/test_core/test_settings_switches_reachable.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""A switch nobody can flip is not a feature.
|
||||
|
||||
Three security switches shipped readable by their enforcement code but with no
|
||||
declared default and no control, so on an air-gapped box they could only be
|
||||
changed by editing the database by hand. These tests pin the two halves that
|
||||
make one real: the default is SEEDED (so the row exists and the settings API
|
||||
can render it), and a PUT through the public API actually changes behaviour.
|
||||
|
||||
collector.legacysharedkeyrefused is the one proven end to end here, because it
|
||||
is core and its enforcement is a plain route refusal.
|
||||
|
||||
Also pinned: the upsert in update_setting must not give an UNDECLARED key
|
||||
valuetype 'string' when the caller sent JSON true. That was how
|
||||
plugin.endpoints.bootstrapquerytoken became a string row whose readers, doing
|
||||
bool(Setting.get(key, True)), then saw the non-empty text 'false' as True.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.api.collector import (
|
||||
LEGACY_SHARED_KEY_SETTING,
|
||||
LEGACY_SHARED_KEY_SETTING_DEFAULT,
|
||||
)
|
||||
from shopdb.core.api.settings import build_default_settings
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
KEY = 'testlegacykey'
|
||||
LEGACY_URL = '/api/collector/heartbeat'
|
||||
LEGACY_BODY = {'hostname': 'WJSW01'}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_key(app):
|
||||
old = app.config.get('COLLECTOR_API_KEY')
|
||||
app.config['COLLECTOR_API_KEY'] = KEY
|
||||
yield KEY
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
def _defaults():
|
||||
return {entry['key']: entry for entry in build_default_settings()}
|
||||
|
||||
|
||||
def test_collector_switch_is_declared_in_the_seed_list():
|
||||
"""Seeding is what creates the row; without it the toggle has nothing to
|
||||
read and the UI has no control to render."""
|
||||
entry = _defaults().get(LEGACY_SHARED_KEY_SETTING)
|
||||
|
||||
assert entry is not None, 'collector.legacysharedkeyrefused is not seeded'
|
||||
assert entry is LEGACY_SHARED_KEY_SETTING_DEFAULT, \
|
||||
'the seed list must reuse the declaration next to the enforcing code'
|
||||
# Today's behaviour: the legacy routes stay open to the shared key.
|
||||
assert entry['value'] == 'false'
|
||||
assert entry['valuetype'] == 'boolean'
|
||||
assert entry['category'] == 'collector'
|
||||
|
||||
|
||||
def test_seeding_creates_the_row_as_a_real_boolean(client, db, auth_headers):
|
||||
resp = client.post('/api/settings/seed', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
row = Setting.query.filter_by(key=LEGACY_SHARED_KEY_SETTING).one()
|
||||
assert row.valuetype == 'boolean'
|
||||
assert row.get_typed_value() is False
|
||||
|
||||
|
||||
def test_the_seeded_switch_is_listed_by_the_settings_api(client, db,
|
||||
auth_headers):
|
||||
"""The settings hub control reads this list, so the key has to be in it."""
|
||||
client.post('/api/settings/seed', headers=auth_headers)
|
||||
|
||||
resp = client.get('/api/settings', headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200
|
||||
items = {item['key']: item for item in resp.get_json()['data']}
|
||||
assert LEGACY_SHARED_KEY_SETTING in items
|
||||
assert items[LEGACY_SHARED_KEY_SETTING]['value'] is False
|
||||
assert items[LEGACY_SHARED_KEY_SETTING]['valuetype'] == 'boolean'
|
||||
|
||||
|
||||
def test_put_flips_the_switch_and_the_legacy_route_closes(client, db,
|
||||
auth_headers,
|
||||
collector_key):
|
||||
"""End to end through the public API: default on, PUT, behaviour changes."""
|
||||
client.post('/api/settings/seed', headers=auth_headers)
|
||||
|
||||
# Default: the shared key still works on a legacy route.
|
||||
assert client.post(LEGACY_URL, json=LEGACY_BODY,
|
||||
headers={'X-API-Key': KEY}).status_code == 200
|
||||
|
||||
flip = client.put('/api/settings/' + LEGACY_SHARED_KEY_SETTING,
|
||||
json={'value': True}, headers=auth_headers)
|
||||
assert flip.status_code == 200, flip.get_json()
|
||||
assert flip.get_json()['data']['value'] is True
|
||||
|
||||
refused = client.post(LEGACY_URL, json=LEGACY_BODY,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert refused.status_code == 401, refused.get_json()
|
||||
assert refused.get_json()['data']['error']['code'] == 'SHARED_KEY_REFUSED'
|
||||
|
||||
# And back off again, or an operator could not undo a mistake.
|
||||
client.put('/api/settings/' + LEGACY_SHARED_KEY_SETTING,
|
||||
json={'value': False}, headers=auth_headers)
|
||||
assert client.post(LEGACY_URL, json=LEGACY_BODY,
|
||||
headers={'X-API-Key': KEY}).status_code == 200
|
||||
|
||||
|
||||
def test_an_undeclared_boolean_key_is_not_born_a_string(client, db,
|
||||
auth_headers):
|
||||
"""The trap that made bootstrapquerytoken misbehave.
|
||||
|
||||
JSON true on a key nobody declares used to create valuetype 'string', and
|
||||
every boolean reader then got the text 'true'/'false' - both truthy.
|
||||
"""
|
||||
key = 'testundeclaredswitch'
|
||||
|
||||
resp = client.put('/api/settings/' + key, json={'value': False},
|
||||
headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
row = Setting.query.filter_by(key=key).one()
|
||||
assert row.valuetype == 'boolean'
|
||||
assert row.get_typed_value() is False
|
||||
assert bool(Setting.get(key, True)) is False
|
||||
|
||||
|
||||
def test_an_undeclared_number_key_is_born_a_number(client, db, auth_headers):
|
||||
key = 'testundeclaredcount'
|
||||
|
||||
client.put('/api/settings/' + key, json={'value': 25},
|
||||
headers=auth_headers)
|
||||
|
||||
row = Setting.query.filter_by(key=key).one()
|
||||
assert row.valuetype == 'integer'
|
||||
assert row.get_typed_value() == 25
|
||||
# And the type is now enforced on the next write, which is the point of
|
||||
# recording it: a typo cannot silently become 0 for every reader.
|
||||
bad = client.put('/api/settings/' + key, json={'value': 'twenty-five'},
|
||||
headers=auth_headers)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_an_undeclared_string_that_reads_true_stays_a_string(client, db,
|
||||
auth_headers):
|
||||
"""Inference reads the JSON type, never the text. A string setting whose
|
||||
value happens to be 'true' must not be coerced into a boolean row."""
|
||||
key = 'testundeclaredlabel'
|
||||
|
||||
client.put('/api/settings/' + key, json={'value': 'true'},
|
||||
headers=auth_headers)
|
||||
|
||||
row = Setting.query.filter_by(key=key).one()
|
||||
assert row.valuetype == 'string'
|
||||
assert row.get_typed_value() == 'true'
|
||||
|
||||
|
||||
def test_a_declared_key_keeps_its_declared_type(client, db, auth_headers):
|
||||
"""Declaration wins over inference: the declaration is the contract every
|
||||
reader was written against."""
|
||||
client.put('/api/settings/' + LEGACY_SHARED_KEY_SETTING,
|
||||
json={'value': 'true'}, headers=auth_headers)
|
||||
|
||||
row = Setting.query.filter_by(key=LEGACY_SHARED_KEY_SETTING).one()
|
||||
assert row.valuetype == 'boolean'
|
||||
assert row.category == 'collector'
|
||||
assert row.get_typed_value() is True
|
||||
|
||||
|
||||
# --- masking is keyed on the NAME, so it has to know the type ----------------
|
||||
#
|
||||
# A settings page cannot render a checkbox it is not allowed to read. The
|
||||
# secret mask is a name heuristic ('password'/'token'/'secret' anywhere in the
|
||||
# key), and plugin.endpoints.bootstrapquerytoken is a true/false switch whose
|
||||
# name contains 'token': the API answered '********' and the control could not
|
||||
# tell on from off.
|
||||
|
||||
def test_a_boolean_named_like_a_secret_is_not_masked(client, db, auth_headers):
|
||||
key = 'plugin.endpoints.bootstrapquerytoken'
|
||||
|
||||
client.put('/api/settings/' + key, json={'value': False},
|
||||
headers=auth_headers)
|
||||
resp = client.get('/api/settings/' + key, headers=auth_headers)
|
||||
|
||||
assert resp.get_json()['data']['value'] is False
|
||||
|
||||
|
||||
def test_a_string_secret_is_still_masked(client, db, auth_headers):
|
||||
"""The narrowing must not open the real secrets: they are string rows."""
|
||||
client.put('/api/settings/zabbix_token', json={'value': 'supersecret'},
|
||||
headers=auth_headers)
|
||||
|
||||
resp = client.get('/api/settings/zabbix_token', headers=auth_headers)
|
||||
|
||||
assert resp.get_json()['data']['value'] == '********'
|
||||
listed = client.get('/api/settings', headers=auth_headers).get_json()['data']
|
||||
body = client.get('/api/settings', headers=auth_headers).get_data(as_text=True)
|
||||
assert any(item['key'] == 'zabbix_token' for item in listed)
|
||||
assert 'supersecret' not in body
|
||||
|
||||
|
||||
def test_the_mask_still_means_unchanged_for_a_string_secret(client, db,
|
||||
auth_headers):
|
||||
client.put('/api/settings/zabbix_token', json={'value': 'supersecret'},
|
||||
headers=auth_headers)
|
||||
|
||||
client.put('/api/settings/zabbix_token', json={'value': '********'},
|
||||
headers=auth_headers)
|
||||
|
||||
assert Setting.get('zabbix_token') == 'supersecret'
|
||||
|
||||
|
||||
# --- the control itself ------------------------------------------------------
|
||||
#
|
||||
# Seeding alone renders nothing: this UI has no generic key/value settings
|
||||
# editor, so a core key with no page is still SQL-only on an air-gapped box.
|
||||
|
||||
def test_the_security_page_carries_the_collector_toggle():
|
||||
from pathlib import Path
|
||||
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
page = (repo / 'frontend' / 'src' / 'views' / 'settings'
|
||||
/ 'AuthenticationSettings.vue').read_text(encoding='utf-8')
|
||||
|
||||
assert LEGACY_SHARED_KEY_SETTING in page, 'no control for the switch'
|
||||
assert 'toggleLegacySharedKey' in page
|
||||
# It has to name what stops reporting, or turning it on is a guess.
|
||||
assert 'Report-AssetToShopDB.ps1' in page
|
||||
assert 'Report-PrintersToShopDB.ps1' in page
|
||||
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