Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
@@ -1,20 +1,26 @@
|
||||
"""Every route the app serves is described in docs/api-inventory.json.
|
||||
|
||||
The inventory is hand-maintained, and deliberately so: its value is the prose -
|
||||
what each endpoint is FOR, what it accepts, a worked example - and none of that
|
||||
can be derived from the code. An audit of all 372 documented operations found
|
||||
zero phantom routes and zero wrong parameter names, so the maintenance itself is
|
||||
in good order.
|
||||
The inventory has two halves. Its STRUCTURE - surface, method, path and the auth
|
||||
each route actually enforces - is derived from the live url_map and the
|
||||
decorators on each view by `gen_openapi.py --refresh`. Its PROSE - what an
|
||||
endpoint is FOR, what it accepts, a worked example - is human, and a refresh
|
||||
never overwrites words that are already there.
|
||||
|
||||
What it cannot do is notice a route somebody added. Twenty-two were serving
|
||||
traffic undocumented, including an entire plugin's surface and every one of the
|
||||
dashboard card endpoints - each of them added after its surface was written.
|
||||
This module is the coverage half of that, and it is the half a generator cannot
|
||||
do for you: a refresh describes whatever is served at the moment it runs, so
|
||||
without a gate the file rots the moment the next route lands.
|
||||
|
||||
So the prose stays hand-written and the COVERAGE is enforced here. Adding an
|
||||
endpoint now means describing it, in the same commit, which is when the author
|
||||
still knows what it is for.
|
||||
It had. On this branch the inventory was not tracked at all, so the skipif below
|
||||
fired, this module reported green while checking nothing, and 24 routes went to
|
||||
production documented nowhere - including the whole endpoint-agent delivery
|
||||
surface and both kiosks. scripts/check-gates-ran.sh now asserts this module RAN,
|
||||
in every checkout rather than only in one with wiki prose in it.
|
||||
|
||||
So: add a route, run `gen_openapi.py --refresh`, and replace the seeded purpose
|
||||
with the reason the route exists - which is while the author still knows it.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
@@ -93,3 +99,147 @@ def test_nothing_documented_has_been_removed():
|
||||
assert not stale, (
|
||||
'%d documented route(s) are no longer served:\n %s'
|
||||
% (len(stale), '\n '.join('%s %s' % row for row in stale)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The other half: the auth string, which nothing gated until now
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The two tests above check that a route EXISTS in both directions. Nothing
|
||||
# checked what the entry SAYS about it, so a wrong or stale auth string passed
|
||||
# every gate in the repo - and auth is the field a site reads to decide what its
|
||||
# guest role and its firewall may reach, and the field the published spec turns
|
||||
# into a machine-readable security requirement.
|
||||
#
|
||||
# It had rotted exactly that way: nine operations documented as needing no
|
||||
# credential while the handler required one, and a kiosk route documented as
|
||||
# ungated when its gate is a single-use setup code. Both survived a refresh,
|
||||
# because a refresh only rewrites; nothing compared.
|
||||
#
|
||||
# So this re-derives auth from the live app with the generator's own classifier
|
||||
# and fails on any disagreement with the committed file. The derivation is
|
||||
# authoritative: hand-editing an auth string in the inventory does not survive
|
||||
# the next --refresh, so the fix for a wrong one is always to teach the sweep in
|
||||
# scripts/gen_openapi.py, or to gate the view.
|
||||
|
||||
|
||||
def _generator():
|
||||
"""scripts/gen_openapi.py, loaded by path.
|
||||
|
||||
It is a script, not a package module, so there is nothing to import. Loading
|
||||
it runs its imports and nothing else - it builds no app until it is asked
|
||||
to.
|
||||
"""
|
||||
path = REPO / 'scripts' / 'gen_openapi.py'
|
||||
spec = importlib.util.spec_from_file_location('gen_openapi', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def derived_auth():
|
||||
"""{(METHOD, path): auth} as the classifier reads the code right now."""
|
||||
generator = _generator()
|
||||
app = create_app('testing')
|
||||
reviewed, blueprints = generator._reviewed_exceptions()
|
||||
derived = {}
|
||||
for rule, methods, endpoint, view in generator._served_routes(app):
|
||||
auth = generator._auth_for(view, endpoint, reviewed, blueprints)
|
||||
for method in methods:
|
||||
derived[(method, normalise(rule))] = auth
|
||||
return derived
|
||||
|
||||
|
||||
def documented_auth():
|
||||
"""{(METHOD, path): auth} as docs/api-inventory.json has it committed."""
|
||||
documented = {}
|
||||
for surface in json.loads(INVENTORY.read_text()):
|
||||
for entry in surface.get('endpoints', []):
|
||||
path = normalise(entry.get('path') or '')
|
||||
for method in re.split(r'[/,|]', entry.get('method') or 'GET'):
|
||||
method = method.strip().upper()
|
||||
if method:
|
||||
documented[(method, path)] = entry.get('auth') or ''
|
||||
return documented
|
||||
|
||||
|
||||
def test_documented_auth_is_what_the_code_enforces():
|
||||
"""A stale auth string is a lie about who may call a route.
|
||||
|
||||
Only routes present on both sides are compared. A route missing from either
|
||||
is a coverage fault, and the two tests above already name it; reporting it
|
||||
twice here would bury this one.
|
||||
"""
|
||||
derived = derived_auth()
|
||||
documented = documented_auth()
|
||||
disagreements = []
|
||||
for key in sorted(set(derived) & set(documented)):
|
||||
if derived[key] != documented[key]:
|
||||
disagreements.append(
|
||||
'%s %s\n documented: %s\n enforced: %s'
|
||||
% (key[0], key[1], documented[key] or '(blank)', derived[key]))
|
||||
assert not disagreements, (
|
||||
'%d route(s) document an auth that is not what the code enforces. The '
|
||||
'inventory is derived, so fix the code or teach the classifier in '
|
||||
'scripts/gen_openapi.py, then re-run it with --refresh:\n %s'
|
||||
% (len(disagreements), '\n '.join(disagreements[:15])))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The third derived field: query parameters
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A Flask rule carries its path arguments and nothing else, so a route whose
|
||||
# real interface is '?code=' or '?search=' documented no parameters at all -
|
||||
# /api/usb/kiosk/setup among them, where '?code=' is the ONLY way to call it
|
||||
# successfully. gen_openapi.py now reads them out of each view's AST and writes
|
||||
# them into the inventory as `queryparams`.
|
||||
#
|
||||
# That field is DERIVED, like auth, and gated the same way and for the same
|
||||
# reason: a refresh only rewrites, so without a comparison a hand-edit or a
|
||||
# stale file publishes a query contract the handler does not implement. A
|
||||
# parameter that is not really there is the worst kind of documentation defect,
|
||||
# because a reader believes it, sends it, and is silently ignored.
|
||||
|
||||
|
||||
def derived_queryparams():
|
||||
"""{(METHOD, path): [parameter, ...]} as the sweep reads the code now."""
|
||||
generator = _generator()
|
||||
app = create_app('testing')
|
||||
derived = {}
|
||||
for rule, methods, endpoint, view in generator._served_routes(app):
|
||||
parameters = generator._query_parameters(view)
|
||||
for method in methods:
|
||||
derived[(method, normalise(rule))] = parameters
|
||||
return derived
|
||||
|
||||
|
||||
def documented_queryparams():
|
||||
"""{(METHOD, path): [parameter, ...]} as the inventory has it committed."""
|
||||
documented = {}
|
||||
for surface in json.loads(INVENTORY.read_text()):
|
||||
for entry in surface.get('endpoints', []):
|
||||
path = normalise(entry.get('path') or '')
|
||||
for method in re.split(r'[/,|]', entry.get('method') or 'GET'):
|
||||
method = method.strip().upper()
|
||||
if method:
|
||||
documented[(method, path)] = entry.get('queryparams') or []
|
||||
return documented
|
||||
|
||||
|
||||
def test_documented_query_parameters_are_what_the_handler_reads():
|
||||
derived = derived_queryparams()
|
||||
documented = documented_queryparams()
|
||||
disagreements = []
|
||||
for key in sorted(set(derived) & set(documented)):
|
||||
if derived[key] != documented[key]:
|
||||
disagreements.append(
|
||||
'%s %s\n documented: %s\n read: %s'
|
||||
% (key[0], key[1],
|
||||
[item['name'] for item in documented[key]],
|
||||
[item['name'] for item in derived[key]]))
|
||||
assert not disagreements, (
|
||||
'%d route(s) document query parameters that are not what the handler '
|
||||
'reads. The field is derived, so regenerate with '
|
||||
'scripts/gen_openapi.py --refresh:\n %s'
|
||||
% (len(disagreements), '\n '.join(disagreements[:15])))
|
||||
|
||||
117
tests/test_backfill_app_fingerprints.py
Normal file
117
tests/test_backfill_app_fingerprints.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""scripts/backfill_app_fingerprints.py against a real (SQLite) session.
|
||||
|
||||
The unique index on (assetid, appfingerprint) turns two identical inventory rows
|
||||
on one asset into an IntegrityError mid-backfill. The script must keep the
|
||||
lowest id, delete the rest and report how many, and a failing batch must be
|
||||
rolled back rather than left half-applied.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parent.parent / 'scripts' / 'backfill_app_fingerprints.py'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backfill(app):
|
||||
spec = importlib.util.spec_from_file_location('backfillfingerprints', SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _asset(db, number):
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=number, assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _row(db, assetid, name, version='1.0', publisher='Vendor',
|
||||
fingerprint=None):
|
||||
from plugins.endpoints.models import EndpointApplication
|
||||
|
||||
row = EndpointApplication(assetid=assetid, displayname=name,
|
||||
displayversion=version, publisher=publisher,
|
||||
**{'appfingerprint': fingerprint})
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row.endpointapplicationid
|
||||
|
||||
|
||||
def test_self_check_uses_fixed_expected_values(backfill, monkeypatch):
|
||||
assert backfill.verify_fingerprint_algorithm() is True
|
||||
monkeypatch.setattr(backfill, 'compute_fingerprint',
|
||||
lambda name, version, publisher: '0' * 64)
|
||||
with pytest.raises(ValueError):
|
||||
backfill.verify_fingerprint_algorithm()
|
||||
|
||||
|
||||
def test_duplicates_keep_the_lowest_id(backfill, db):
|
||||
from plugins.endpoints.models import EndpointApplication
|
||||
|
||||
first = _asset(db, 'FP01')
|
||||
second = _asset(db, 'FP02')
|
||||
keep = _row(db, first, 'Chrome')
|
||||
_row(db, first, 'Chrome')
|
||||
_row(db, first, 'Chrome')
|
||||
other = _row(db, first, 'Office')
|
||||
elsewhere = _row(db, second, 'Chrome')
|
||||
|
||||
summary = backfill.backfill_fingerprints(batch_size=2)
|
||||
|
||||
assert summary['duplicatesremoved'] == 2
|
||||
assert summary['failed'] == 0
|
||||
rows = EndpointApplication.query.order_by(
|
||||
EndpointApplication.endpointapplicationid).all()
|
||||
assert [r.endpointapplicationid for r in rows] == [keep, other, elsewhere]
|
||||
assert all(r.appfingerprint for r in rows)
|
||||
|
||||
|
||||
def test_an_existing_higher_id_holder_gives_way(backfill, db):
|
||||
from plugins.endpoints.models import EndpointApplication
|
||||
|
||||
assetid = _asset(db, 'FP03')
|
||||
fingerprint = backfill.compute_fingerprint('Chrome', '1.0', 'Vendor')
|
||||
older = _row(db, assetid, 'Chrome')
|
||||
_row(db, assetid, 'Chrome', fingerprint=fingerprint)
|
||||
|
||||
summary = backfill.backfill_fingerprints()
|
||||
|
||||
assert summary['duplicatesremoved'] == 1
|
||||
rows = EndpointApplication.query.all()
|
||||
assert [(r.endpointapplicationid, r.appfingerprint) for r in rows] == \
|
||||
[(older, fingerprint)]
|
||||
|
||||
|
||||
def test_a_failing_batch_is_rolled_back(backfill, db, monkeypatch):
|
||||
from plugins.endpoints.models import EndpointApplication
|
||||
|
||||
assetid = _asset(db, 'FP04')
|
||||
_row(db, assetid, 'Chrome')
|
||||
_row(db, assetid, 'Office')
|
||||
|
||||
def broken(apps):
|
||||
for app in apps:
|
||||
app.appfingerprint = 'x' * 64
|
||||
raise RuntimeError('simulated failure')
|
||||
|
||||
monkeypatch.setattr(backfill, '_fingerprint_batch', broken)
|
||||
|
||||
summary = backfill.backfill_fingerprints()
|
||||
|
||||
assert summary['failed'] == 2
|
||||
assert summary['processed'] == 0
|
||||
db.session.expire_all()
|
||||
assert EndpointApplication.query.filter(
|
||||
EndpointApplication.appfingerprint.isnot(None)).count() == 0
|
||||
@@ -1,231 +0,0 @@
|
||||
"""The installer bundle lock: fail-closed behaviour, and parity between the two
|
||||
implementations that read it.
|
||||
|
||||
There are two verifiers on purpose - bundle-lock.ps1 runs at install time on a
|
||||
server where PowerShell is the only thing present, verify_bundle_lock.py runs in
|
||||
the Linux builder without adding pwsh as a build dependency. Two implementations
|
||||
of one rule drift. These tests run BOTH against the same fixtures and fail if
|
||||
they disagree, so the drift shows up here rather than as a bundle that one of
|
||||
them waves through.
|
||||
|
||||
The PowerShell half is skipped where pwsh is absent; the Python half always runs.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
INSTALLER = Path(__file__).resolve().parents[1] / 'deploy' / 'windows' / 'installer'
|
||||
VERIFY_PY = INSTALLER / 'verify_bundle_lock.py'
|
||||
LOCK_PS1 = INSTALLER / 'bundle-lock.ps1'
|
||||
PWSH = shutil.which('pwsh') or shutil.which('powershell')
|
||||
|
||||
pytestmark = pytest.mark.skipif(not VERIFY_PY.exists(), reason='installer not in this tree')
|
||||
|
||||
|
||||
def build_bundle(root):
|
||||
"""A minimal bundle holding every REQUIRED payload directory."""
|
||||
for name, files in (
|
||||
('wheels', {'alembic-1.18.4-py3-none-any.whl': b'wheel-a',
|
||||
'cffi-2.1.0-cp314-cp314-win_amd64.whl': b'wheel-b'}),
|
||||
('python', {'python-3.14.6-amd64.exe': b'py'}),
|
||||
('httpplatformhandler', {'httpPlatformHandler_amd64.msi': b'hph'}),
|
||||
):
|
||||
directory = root / name
|
||||
directory.mkdir(parents=True)
|
||||
for filename, content in files.items():
|
||||
(directory / filename).write_bytes(content)
|
||||
return root
|
||||
|
||||
|
||||
def write_lock(bundle, lock_path):
|
||||
"""Generate the lock the same way refresh-bundle-lock.ps1 does."""
|
||||
sys.path.insert(0, str(INSTALLER))
|
||||
import verify_bundle_lock as verifier
|
||||
|
||||
payloads = {}
|
||||
for name, required, _what in verifier.PAYLOADS:
|
||||
directory = bundle / name
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
payloads[name] = {'required': required, 'files': verifier.payload_files(str(directory))}
|
||||
lock_path.write_text(json.dumps({
|
||||
'schema': 1, 'generated': '2026-08-03T00:00:00Z',
|
||||
'pythontag': 'cp314', 'platform': 'win_amd64', 'payloads': payloads,
|
||||
}))
|
||||
|
||||
|
||||
def check_python(bundle, lock_path):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(VERIFY_PY), str(bundle), str(lock_path)],
|
||||
capture_output=True, text=True)
|
||||
problems = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
# Exit status and output must agree, or a caller that checks only one of them
|
||||
# gets a different answer from a caller that checks the other.
|
||||
assert (result.returncode != 0) == bool(problems), result.stdout
|
||||
return problems
|
||||
|
||||
|
||||
def check_powershell(bundle, lock_path):
|
||||
script = (
|
||||
". '%s'; $p = Test-BundleLock -BundleRoot '%s' -Lock (Read-BundleLock '%s'); "
|
||||
"if ($p) { $p -join \"`n\" }" % (LOCK_PS1, bundle, lock_path))
|
||||
result = subprocess.run([PWSH, '-NoProfile', '-Command', script],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def check_both(bundle, lock_path):
|
||||
"""Returns the Python verdict, asserting PowerShell reports the same set.
|
||||
|
||||
Compared as sets: both sort their output, but PowerShell's Sort-Object is
|
||||
culture-aware and Python's sorted() is ordinal, so the two can order the same
|
||||
findings differently. What must never differ is WHICH problems are found.
|
||||
"""
|
||||
problems = check_python(bundle, lock_path)
|
||||
if PWSH and LOCK_PS1.exists():
|
||||
assert sorted(check_powershell(bundle, lock_path)) == sorted(problems), (
|
||||
'verify_bundle_lock.py and bundle-lock.ps1 disagree')
|
||||
return problems
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def locked(tmp_path):
|
||||
bundle = build_bundle(tmp_path / 'bundle')
|
||||
lock = tmp_path / 'bundle-lock.json'
|
||||
write_lock(bundle, lock)
|
||||
return bundle, lock
|
||||
|
||||
|
||||
def test_untouched_bundle_passes(locked):
|
||||
bundle, lock = locked
|
||||
assert check_both(bundle, lock) == []
|
||||
|
||||
|
||||
def test_extra_file_fails(locked):
|
||||
"""The gap pip's own hash checking leaves: a stale wheel nobody asked for."""
|
||||
bundle, lock = locked
|
||||
(bundle / 'wheels' / 'stale-0.1-py3-none-any.whl').write_bytes(b'left over')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('stale-0.1' in p and 'NOT in the lock' in p for p in problems)
|
||||
|
||||
|
||||
def test_altered_file_fails(locked):
|
||||
bundle, lock = locked
|
||||
(bundle / 'wheels' / 'cffi-2.1.0-cp314-cp314-win_amd64.whl').write_bytes(b'swapped')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('does NOT match the lock' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_file_fails(locked):
|
||||
bundle, lock = locked
|
||||
(bundle / 'wheels' / 'alembic-1.18.4-py3-none-any.whl').unlink()
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('missing from the bundle' in p for p in problems)
|
||||
|
||||
|
||||
def test_missing_required_payload_fails(locked):
|
||||
bundle, lock = locked
|
||||
shutil.rmtree(bundle / 'python')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any(p.startswith('python/') for p in problems)
|
||||
|
||||
|
||||
def test_unlocked_payload_directory_fails(locked):
|
||||
"""An optional payload dropped in after the lock was made is still unreviewed."""
|
||||
bundle, lock = locked
|
||||
(bundle / 'mysql').mkdir()
|
||||
(bundle / 'mysql' / 'mysql-8.0.46-winx64.msi').write_bytes(b'msi')
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('mysql/ is present but is not in bundle-lock.json' in p for p in problems)
|
||||
|
||||
|
||||
def test_absent_optional_payload_is_fine(locked):
|
||||
bundle, lock = locked
|
||||
assert not (bundle / 'mysql').exists()
|
||||
assert check_both(bundle, lock) == []
|
||||
|
||||
|
||||
def test_missing_lock_fails_closed(locked):
|
||||
"""No lock is a failure, not a pass. The build must not fall through to 'fine'."""
|
||||
bundle, lock = locked
|
||||
lock.unlink()
|
||||
result = subprocess.run([sys.executable, str(VERIFY_PY), str(bundle), str(lock)],
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode != 0
|
||||
assert 'no bundle-lock.json' in result.stdout
|
||||
|
||||
|
||||
def test_malformed_lock_reports_rather_than_crashing(locked):
|
||||
"""A truncated lock must produce a sentence, not a stack trace.
|
||||
|
||||
shopdb-install.ps1 runs under Set-StrictMode 2.0, where reading a property
|
||||
that is not there throws. Both verifiers have to survive a lock missing the
|
||||
section they read first.
|
||||
"""
|
||||
bundle, lock = locked
|
||||
lock.write_text(json.dumps({'schema': 1, 'pythontag': 'cp314'}))
|
||||
problems = check_both(bundle, lock)
|
||||
assert problems == ['bundle-lock.json has no "payloads" section']
|
||||
|
||||
|
||||
def test_lock_missing_files_section_is_not_a_silent_pass(locked):
|
||||
"""A payload entry with no file list must not read as 'nothing expected, fine'."""
|
||||
bundle, lock = locked
|
||||
data = json.loads(lock.read_text())
|
||||
del data['payloads']['wheels']['files']
|
||||
lock.write_text(json.dumps(data))
|
||||
problems = check_both(bundle, lock)
|
||||
assert problems, 'an entry with no file list let every wheel through'
|
||||
assert all('wheels/' in p for p in problems)
|
||||
|
||||
|
||||
def test_wheelhouse_must_cover_requirements(locked):
|
||||
"""The regression that motivated the check.
|
||||
|
||||
A wheelhouse assembled anywhere but Windows omits colorama - pip evaluates
|
||||
sys_platform markers against the downloading machine. The lock alone cannot
|
||||
catch it, because the lock records what is there, not what is needed.
|
||||
"""
|
||||
bundle, lock = locked
|
||||
app = bundle / 'app'
|
||||
app.mkdir()
|
||||
(app / 'requirements.txt').write_text(
|
||||
"alembic==1.18.4 \\\n --hash=sha256:abc\n"
|
||||
"colorama==0.4.6 ; sys_platform == 'win32' \\\n --hash=sha256:def\n")
|
||||
problems = check_both(bundle, lock)
|
||||
assert any('colorama==0.4.6' in p for p in problems), problems
|
||||
assert not any('alembic' in p for p in problems), 'alembic has a wheel and must not be flagged'
|
||||
|
||||
|
||||
def test_wheelhouse_coverage_normalizes_names(locked):
|
||||
"""mysql-connector-python pins as dashes and ships as mysql_connector_python."""
|
||||
bundle, lock = locked
|
||||
app = bundle / 'app'
|
||||
app.mkdir()
|
||||
(app / 'requirements.txt').write_text('mysql-connector-python==9.7.0\n')
|
||||
assert any('mysql_connector_python==9.7.0' in p for p in check_both(bundle, lock))
|
||||
|
||||
wheel = bundle / 'wheels' / 'mysql_connector_python-9.7.0-cp314-cp314-win_amd64.whl'
|
||||
wheel.write_bytes(b'connector')
|
||||
write_lock(bundle, lock) # re-lock so the new wheel is not an 'extra'
|
||||
assert check_both(bundle, lock) == []
|
||||
|
||||
|
||||
def test_payload_list_matches_powershell():
|
||||
"""Both files enumerate the payload directories. Same names, same required
|
||||
flags, same order - a directory that is required in one and optional in the
|
||||
other means the builder and the installer disagree about what may be absent.
|
||||
"""
|
||||
sys.path.insert(0, str(INSTALLER))
|
||||
import verify_bundle_lock as verifier
|
||||
|
||||
declared = re.findall(r"Name\s*=\s*'([a-z]+)'\s*;\s*Required\s*=\s*\$(true|false)",
|
||||
LOCK_PS1.read_text())
|
||||
assert declared, 'could not find $script:BundlePayloads in bundle-lock.ps1'
|
||||
assert declared == [(name, str(required).lower()) for name, required, _ in verifier.PAYLOADS]
|
||||
216
tests/test_core/test_api_docs_tryit.py
Normal file
216
tests/test_core/test_api_docs_tryit.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""The Swagger UI "try it" page at /api/docs/try.
|
||||
|
||||
Two things are being proved, and they are the two that would hurt on the
|
||||
production box:
|
||||
|
||||
1. It is OFF unless a site turns it on, and off means the URL does not exist.
|
||||
The page fires real requests at this server with the browser's own session,
|
||||
so a disabled deployment must not serve it, advertise it, or confirm it is
|
||||
a thing by answering anything other than 404.
|
||||
|
||||
2. Nothing it loads comes from the internet. Production is air-gapped: a CDN
|
||||
reference, a webfont, or a source map pointing at a public host is not a
|
||||
slow page, it is a page that never renders and a request that hangs.
|
||||
|
||||
These build their own Flask app around the docs blueprint rather than using the
|
||||
session app from conftest. The blueprint touches no database, and a second
|
||||
create_app() would rebind the SQLAlchemy extension and break whatever module
|
||||
runs next (see the note on the gated_app fixture in test_public_read_surface).
|
||||
It also lets one test see the flag on and the next see it off, which is the
|
||||
whole behaviour under test.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from shopdb.core.api.docs import docs_bp
|
||||
|
||||
# Everything the try-it page is allowed to load, and the files behind them.
|
||||
_CSS_PATH = '/api/docs/swagger-ui.css'
|
||||
_JS_PATH = '/api/docs/swagger-ui-bundle.min.js'
|
||||
_STATICDOCS = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
'shopdb', 'core', 'api', 'staticdocs')
|
||||
_VENDORED = ('swagger-ui.css', 'swagger-ui-bundle.min.js')
|
||||
|
||||
|
||||
def _docs_app(tryit_enabled):
|
||||
"""A bare app carrying only the docs blueprint, mounted where the real app
|
||||
mounts it (register_blueprints uses /api/<name>)."""
|
||||
application = Flask(__name__)
|
||||
application.config['API_DOCS_TRYIT_ENABLED'] = tryit_enabled
|
||||
application.register_blueprint(docs_bp, url_prefix='/api/docs')
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enabled_client():
|
||||
return _docs_app(True).test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disabled_client():
|
||||
return _docs_app(False).test_client()
|
||||
|
||||
|
||||
def test_enabled_page_renders_swagger_over_the_published_spec(enabled_client):
|
||||
response = enabled_client.get('/api/docs/try')
|
||||
assert response.status_code == 200
|
||||
page = response.get_data(as_text=True)
|
||||
assert 'SwaggerUIBundle(' in page
|
||||
assert 'id="swagger-ui"' in page
|
||||
# One spec, two renderers: the try-it page must read the same document the
|
||||
# Redoc page serves, not a second copy that could drift from it.
|
||||
assert '/api/docs/openapi.json' in page
|
||||
|
||||
|
||||
def test_the_page_references_no_other_origin_at_all(enabled_client):
|
||||
"""Absolute AND protocol-relative.
|
||||
|
||||
The first version of this scanned for https?://, so
|
||||
<script src="//cdn.jsdelivr.net/npm/x.js"> - the form a copy-paste from any
|
||||
CDN's own docs produces - matched nothing and passed.
|
||||
"""
|
||||
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
|
||||
offenders = re.findall(r'(?:src|href)\s*=\s*[\'"]((?:https?:)?//[^\'"]+)', page)
|
||||
assert not offenders, 'the try-it page loads from another origin: %s' % offenders
|
||||
|
||||
|
||||
def test_the_redoc_page_survives_try_it_being_unmounted(app):
|
||||
"""The banner linking to the try-it page is built with url_for, and url_for
|
||||
raises BuildError when those routes are not registered - which is every
|
||||
production box. An unconditional link therefore 500s /api/docs/ in exactly
|
||||
the deployment nobody tests locally.
|
||||
"""
|
||||
# The testing config is the unmounted case, and it is the one available
|
||||
# here: ProductionConfig refuses to build without a real SECRET_KEY, which
|
||||
# is correct of it and makes it useless as a fixture.
|
||||
page = app.test_client().get('/api/docs/')
|
||||
|
||||
assert page.status_code == 200, page.get_data(as_text=True)[:200]
|
||||
body = page.get_data(as_text=True)
|
||||
assert 'redoc' in body.lower()
|
||||
assert '/api/docs/try' not in body
|
||||
|
||||
|
||||
def test_enabled_page_loads_only_the_vendored_assets(enabled_client):
|
||||
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
|
||||
assert 'href="%s"' % _CSS_PATH in page
|
||||
assert 'src="%s"' % _JS_PATH in page
|
||||
for path, expected_type in ((_CSS_PATH, 'text/css'),
|
||||
(_JS_PATH, 'application/javascript')):
|
||||
asset = enabled_client.get(path)
|
||||
assert asset.status_code == 200, path
|
||||
assert expected_type in asset.headers['Content-Type']
|
||||
assert asset.headers.get('Content-Length', '0') != '0'
|
||||
|
||||
|
||||
def test_the_page_names_no_external_host(enabled_client):
|
||||
"""Every URL on the page has to be same-origin. On an air-gapped box an
|
||||
absolute http(s) URL is a request that hangs until it times out."""
|
||||
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
|
||||
external = re.findall(r'https?://[^\s"\'<>)]+', page)
|
||||
assert not external, 'try-it page references external hosts: %s' % external
|
||||
|
||||
|
||||
def test_the_spec_validator_is_turned_off(enabled_client):
|
||||
"""Swagger UI's one outbound default: unset, it posts the spec to a public
|
||||
validator to draw a badge. That is a hung request here, and it would be an
|
||||
upload of the internal API surface anywhere it worked."""
|
||||
page = enabled_client.get('/api/docs/try').get_data(as_text=True)
|
||||
assert re.search(r'validatorUrl:\s*null', page), (
|
||||
'validatorUrl is not disabled; Swagger UI will call the public '
|
||||
'validator')
|
||||
|
||||
|
||||
def test_disabled_deployment_has_no_such_route(disabled_client):
|
||||
"""404, not a rendered page explaining that it is disabled. The route is
|
||||
never bound, so the URL confirms nothing."""
|
||||
for path in ('/api/docs/try', '/api/docs/try/', _CSS_PATH, _JS_PATH):
|
||||
assert disabled_client.get(path).status_code == 404, path
|
||||
|
||||
|
||||
def test_the_redoc_page_advertises_try_it_only_when_it_exists(
|
||||
enabled_client, disabled_client):
|
||||
enabled_page = enabled_client.get('/api/docs/').get_data(as_text=True)
|
||||
assert '/api/docs/try' in enabled_page
|
||||
|
||||
disabled_page = disabled_client.get('/api/docs/').get_data(as_text=True)
|
||||
assert '/api/docs/try' not in disabled_page
|
||||
# The word too, not just the href: a note about a view that 404s is worse
|
||||
# than no note.
|
||||
assert 'try-it' not in disabled_page
|
||||
|
||||
|
||||
def test_every_url_follows_the_mount_path(enabled_client):
|
||||
"""Prod runs under /shopdb. url_for has to carry the prefix onto the spec
|
||||
and both assets, or the page loads nothing and can validate nothing."""
|
||||
page = enabled_client.get(
|
||||
'/api/docs/try',
|
||||
environ_overrides={'SCRIPT_NAME': '/shopdb'}).get_data(as_text=True)
|
||||
assert '/shopdb/api/docs/openapi.json' in page
|
||||
assert '/shopdb%s' % _CSS_PATH in page
|
||||
assert '/shopdb%s' % _JS_PATH in page
|
||||
# No unprefixed twin left behind by a hardcoded path.
|
||||
assert 'href="%s"' % _CSS_PATH not in page
|
||||
assert 'src="%s"' % _JS_PATH not in page
|
||||
|
||||
redoc_page = enabled_client.get(
|
||||
'/api/docs/',
|
||||
environ_overrides={'SCRIPT_NAME': '/shopdb'}).get_data(as_text=True)
|
||||
assert '/shopdb/api/docs/try' in redoc_page
|
||||
|
||||
|
||||
@pytest.mark.parametrize('filename', _VENDORED)
|
||||
def test_a_vendored_asset_fetches_nothing_from_the_internet(filename):
|
||||
"""The assets themselves, not just the page.
|
||||
|
||||
Scoped to what a BROWSER would actually request. The swagger-ui bundle
|
||||
carries around a hundred http(s) strings that are prose - RFC and
|
||||
json-schema citations, licence URLs, the example.com host in the spec's own
|
||||
documentation - and none of them is fetched, so a blanket "no http in this
|
||||
file" assertion would be a lie that fails on the next upstream bump. What
|
||||
would really produce a request is a CDN or webfont reference, a stylesheet
|
||||
url() pointing off-box, or a source map: hence these.
|
||||
"""
|
||||
body = open(os.path.join(_STATICDOCS, filename),
|
||||
encoding='utf-8', errors='replace').read()
|
||||
|
||||
# NOT a host allowlist. The first version of this named five CDNs, so an
|
||||
# upstream bump to esm.sh or cdn.skypack.dev walked straight through it, and
|
||||
# the one real leak in this directory was cdn.redoc.ly - which was on no
|
||||
# list. Match the SHAPES a browser fetches instead, whatever the host:
|
||||
# a stylesheet import, a css url(), a script/link/img source.
|
||||
fetching = [
|
||||
(r'@import\s+(?:url\()?[\'"]?(?:https?:)?//', 'an @import from another origin'),
|
||||
(r'url\(\s*[\'"]?(?:https?:)?//', 'a css url() on another origin'),
|
||||
(r'(?:src|href)\s*=\s*[\'"](?:https?:)?//', 'an element loaded from another origin'),
|
||||
(r'@font-face', 'a webfont, which is a fetch unless it is a data: URI'),
|
||||
]
|
||||
for pattern, what in fetching:
|
||||
for match in re.finditer(pattern, body):
|
||||
window = body[max(0, match.start() - 60):match.start() + 120]
|
||||
# data: URIs are inline, not fetched, and this CSS is full of them.
|
||||
if 'data:' in window[:len(window) // 2 + 40]:
|
||||
continue
|
||||
raise AssertionError(
|
||||
'%s carries %s: ...%s...' % (filename, what, window.strip()[:140]))
|
||||
|
||||
# A map reference is a request too. The upstream .map files are not
|
||||
# vendored (they are tens of megabytes and only devtools reads them), so
|
||||
# any surviving pointer is a guaranteed 404 on a box that cannot reach the
|
||||
# internet to try elsewhere.
|
||||
assert 'sourceMappingURL' not in body, (
|
||||
'%s still points at a source map that is not vendored' % filename)
|
||||
|
||||
# Stylesheet resources: swagger-ui inlines its images as data: URIs, and
|
||||
# anything else would be an off-box fetch on first paint. CSS only - in the
|
||||
# minified bundle "url(" is a function call, not a resource.
|
||||
if filename.endswith('.css'):
|
||||
for match in re.findall(r'url\(\s*["\']?([^"\')]+)', body):
|
||||
assert match.startswith('data:'), (
|
||||
'%s loads %s rather than an inline data: URI'
|
||||
% (filename, match))
|
||||
@@ -310,6 +310,62 @@ def test_scope_ceiling_rejects_permission_owner_lacks(client, db):
|
||||
assert 'applications.create' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_direct_grant_raises_the_scope_ceiling(client, db):
|
||||
"""A direct per-user grant is an ordinary permission: it lifts the token
|
||||
ceiling exactly as a role-held one does. If getpermissions() and
|
||||
haspermission() ever disagree about direct grants, this fails."""
|
||||
from shopdb.core.models import Permission, User
|
||||
|
||||
_user, headers = _user_with_perms(client, db, 'directmaker',
|
||||
['apitokens.create'])
|
||||
|
||||
# Not held any way yet: the ceiling must refuse it.
|
||||
refused = _create_token(client, headers, scopes=['applications.create'])
|
||||
assert refused.status_code == 400
|
||||
assert 'applications.create' in refused.get_json()['data']['error']['message']
|
||||
|
||||
user = User.query.filter_by(username='directmaker').one()
|
||||
user.directpermissions = [
|
||||
Permission.query.filter_by(name='applications.create').one()]
|
||||
db.session.commit()
|
||||
|
||||
allowed = _create_token(client, headers, name='direct token',
|
||||
scopes=['applications.create'])
|
||||
assert allowed.status_code == 201, allowed.get_json()
|
||||
|
||||
secret = allowed.get_json()['data']['secret']
|
||||
used = client.post('/api/applications', json={'appname': 'Direct App'},
|
||||
headers=_pat_headers(secret))
|
||||
assert used.status_code == 201, used.get_json()
|
||||
|
||||
|
||||
def test_revoking_a_direct_grant_shrinks_an_existing_token(client, db):
|
||||
"""Ceilings are computed live precisely so revocation bites immediately.
|
||||
Pulling the direct grant must disarm a token already minted against it."""
|
||||
from shopdb.core.models import Permission, User
|
||||
|
||||
_user, headers = _user_with_perms(client, db, 'revokee',
|
||||
['apitokens.create'])
|
||||
user = User.query.filter_by(username='revokee').one()
|
||||
user.directpermissions = [
|
||||
Permission.query.filter_by(name='applications.create').one()]
|
||||
db.session.commit()
|
||||
|
||||
secret = _create_token(client, headers, name='doomed token',
|
||||
scopes=['applications.create']
|
||||
).get_json()['data']['secret']
|
||||
assert client.post('/api/applications', json={'appname': 'Before Revoke'},
|
||||
headers=_pat_headers(secret)).status_code == 201
|
||||
|
||||
user = User.query.filter_by(username='revokee').one()
|
||||
user.directpermissions = []
|
||||
db.session.commit()
|
||||
|
||||
after = client.post('/api/applications', json={'appname': 'After Revoke'},
|
||||
headers=_pat_headers(secret))
|
||||
assert after.status_code == 403, after.get_json()
|
||||
|
||||
|
||||
def test_non_admin_scoped_token_capped_at_own_permissions(client, db):
|
||||
"""A non-admin holding applications.create + apitokens.create may mint a
|
||||
token scoped to applications.create and use it."""
|
||||
|
||||
139
tests/test_core/test_application_hard_delete.py
Normal file
139
tests/test_core/test_application_hard_delete.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Tests for permanently deleting an application.
|
||||
|
||||
This used to be a soft delete, and that was the bug: setting isactive=False
|
||||
left the row in the table, so the name stayed reserved forever. Creating an
|
||||
application with the same name failed with "already exists" pointing at a row
|
||||
no screen would show. Nothing in the UI could explain or clear it.
|
||||
|
||||
So delete is now a real delete, and these tests pin the parts that are easy to
|
||||
get wrong: the dependent rows that would otherwise orphan or block the delete,
|
||||
the uploaded files that would otherwise linger on disk, and the typed
|
||||
confirmation that stops it happening on a stray click.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Application, AppVersion
|
||||
|
||||
from plugins.computers.models import ConfigurationPolicy
|
||||
from plugins.computers.services.apppolicy import auto_policy_name
|
||||
|
||||
|
||||
def _app(db, name='Test App', **kwargs):
|
||||
app = Application(appname=name, **kwargs)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _delete(client, headers, appid, confirm='OK'):
|
||||
return client.delete(f'/api/applications/{appid}',
|
||||
json={'confirm': confirm} if confirm is not None else {},
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_delete_removes_the_row_entirely(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
appid = app.appid
|
||||
|
||||
response = _delete(client, auth_headers, appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert db.session.get(Application, appid) is None
|
||||
|
||||
|
||||
def test_the_name_is_reusable_after_a_delete(client, db, auth_headers):
|
||||
# The bug this change exists to fix. A soft-deleted row kept its name
|
||||
# reserved and the conflict named an application the user could not see.
|
||||
app = _app(db, name='DeviceLogger')
|
||||
_delete(client, auth_headers, app.appid)
|
||||
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': 'DeviceLogger'},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_delete_is_refused_without_the_typed_confirmation(client, db,
|
||||
auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = _delete(client, auth_headers, app.appid, confirm=None)
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
assert db.session.get(Application, app.appid) is not None
|
||||
|
||||
|
||||
def test_delete_is_refused_when_the_confirmation_is_wrong_case(client, db,
|
||||
auth_headers):
|
||||
# Accepting "ok" would defeat the point of asking for deliberate input.
|
||||
app = _app(db)
|
||||
|
||||
response = _delete(client, auth_headers, app.appid, confirm='ok')
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
assert db.session.get(Application, app.appid) is not None
|
||||
|
||||
|
||||
def test_delete_takes_the_versions_with_it(client, db, auth_headers):
|
||||
# appversions.appid is NOT NULL, so leaving these behind would either
|
||||
# orphan them or block the delete outright.
|
||||
app = _app(db)
|
||||
db.session.add(AppVersion(appid=app.appid, version='1.0.0.0'))
|
||||
db.session.commit()
|
||||
appid = app.appid
|
||||
|
||||
response = _delete(client, auth_headers, appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert AppVersion.query.filter_by(appid=appid).count() == 0
|
||||
|
||||
|
||||
def test_delete_withdraws_the_generated_policy(client, db, auth_headers):
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': 'Fleet App', 'isrequired': True,
|
||||
'acknowledgepolicy': True},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201, response.get_json()
|
||||
app = Application.query.filter_by(appname='Fleet App').first()
|
||||
assert ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first() is not None
|
||||
|
||||
_delete(client, auth_headers, app.appid)
|
||||
|
||||
assert ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first() is None
|
||||
|
||||
|
||||
def test_delete_removes_the_uploaded_installer(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
upload = client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (__import__('io').BytesIO(b'MZfake'),
|
||||
'setup.exe')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
assert upload.status_code == 200, upload.get_json()
|
||||
|
||||
stored = db.session.get(Application, app.appid).installpath
|
||||
assert stored
|
||||
|
||||
from shopdb.core.api.applications import _apppackage_dir
|
||||
packagedir = os.path.join(_apppackage_dir(), str(app.appid))
|
||||
# Guard the guard: if the upload did not land where we think, the
|
||||
# post-delete assertion below would pass without proving anything.
|
||||
assert os.listdir(packagedir) == ['setup.exe']
|
||||
|
||||
_delete(client, auth_headers, app.appid)
|
||||
|
||||
# Nothing may remain on disk for this application.
|
||||
assert not os.path.isdir(packagedir)
|
||||
|
||||
|
||||
def test_delete_requires_the_delete_permission(client, db, member_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = _delete(client, member_headers, app.appid)
|
||||
|
||||
assert response.status_code in (401, 403), response.get_json()
|
||||
assert db.session.get(Application, app.appid) is not None
|
||||
142
tests/test_core/test_application_monitorable.py
Normal file
142
tests/test_core/test_application_monitorable.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Tests for the monitorable flag and the two fields it depends on.
|
||||
|
||||
A monitorable application tells the agent to watch for a process and relaunch it
|
||||
when it goes missing. That needs an image name to watch for and a path to run,
|
||||
so the flag is meaningless without both - a monitor with no process name has
|
||||
nothing to check, and one with no launch path has nothing to do about it.
|
||||
|
||||
The rule lives in _validate_monitorable rather than in a DB constraint so the
|
||||
columns stay nullable for rows that predate the feature. That makes it a rule
|
||||
code has to keep choosing to apply, which is what these tests pin: it must hold
|
||||
on create AND on update, including the update that only ticks the box and sends
|
||||
nothing else.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
|
||||
def _app(db, name='Test App', **kwargs):
|
||||
app = Application(appname=name, **kwargs)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _create(client, headers, **fields):
|
||||
payload = {'appname': 'Monitored App'}
|
||||
payload.update(fields)
|
||||
return client.post('/api/applications', json=payload, headers=headers)
|
||||
|
||||
|
||||
def _update(client, headers, appid, **fields):
|
||||
return client.put(f'/api/applications/{appid}', json=fields,
|
||||
headers=headers)
|
||||
|
||||
|
||||
def _message(response):
|
||||
"""The error text, which sits at data.error.message rather than top level."""
|
||||
return response.get_json()['data']['error']['message'].lower()
|
||||
|
||||
|
||||
def test_creating_a_monitorable_app_needs_a_process_name(client, auth_headers):
|
||||
response = _create(client, auth_headers, ismonitorable=True,
|
||||
monitorlaunchpath=r'C:\Vendor\app.exe')
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'process name' in _message(response)
|
||||
|
||||
|
||||
def test_creating_a_monitorable_app_needs_a_launch_path(client, auth_headers):
|
||||
response = _create(client, auth_headers, ismonitorable=True,
|
||||
monitorprocessname='app.exe')
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'launch path' in _message(response)
|
||||
|
||||
|
||||
def test_a_monitorable_app_with_both_fields_saves(client, auth_headers, db):
|
||||
response = _create(client, auth_headers, ismonitorable=True,
|
||||
monitorprocessname='app.exe',
|
||||
monitorlaunchpath=r'C:\Vendor\app.exe')
|
||||
|
||||
assert response.status_code == 201, response.get_json()
|
||||
app = Application.query.filter_by(appname='Monitored App').first()
|
||||
assert app.ismonitorable is True
|
||||
assert app.monitorprocessname == 'app.exe'
|
||||
|
||||
|
||||
def test_a_non_monitorable_app_needs_neither_field(client, auth_headers):
|
||||
# The rule must not leak onto ordinary applications, which are the majority.
|
||||
response = _create(client, auth_headers, ismonitorable=False)
|
||||
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_ticking_the_box_alone_is_refused(client, auth_headers, db):
|
||||
"""The case a payload-only check would miss.
|
||||
|
||||
The form sends just the changed field, so an update that ticks monitorable
|
||||
on a record with no launch path or process name arrives carrying nothing
|
||||
else. Validating the payload in isolation would see no conflict and let it
|
||||
through, storing a monitor that cannot run.
|
||||
"""
|
||||
app = _app(db, name='Plain App')
|
||||
|
||||
response = _update(client, auth_headers, app.appid, ismonitorable=True)
|
||||
|
||||
assert response.status_code == 400
|
||||
# Launch path is checked first, so that is the field named. What matters is
|
||||
# that the merged state was rejected at all, not which half it complained
|
||||
# about.
|
||||
assert 'launch path' in _message(response)
|
||||
|
||||
|
||||
def test_ticking_the_box_is_allowed_when_the_row_already_has_the_fields(
|
||||
client, auth_headers, db):
|
||||
# The mirror of the above: validation reads the MERGED result, so stored
|
||||
# values satisfy it and the operator is not asked to retype them.
|
||||
app = _app(db, name='Ready App', monitorprocessname='app.exe',
|
||||
monitorlaunchpath=r'C:\Vendor\app.exe')
|
||||
|
||||
response = _update(client, auth_headers, app.appid, ismonitorable=True)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert Application.query.get(app.appid).ismonitorable is True
|
||||
|
||||
|
||||
def test_clearing_a_field_on_a_monitorable_app_is_refused(client, auth_headers,
|
||||
db):
|
||||
# Blanking the process name is the same broken state as never setting it,
|
||||
# so it has to be refused from this direction too.
|
||||
app = _app(db, name='Live Monitor', ismonitorable=True,
|
||||
monitorprocessname='app.exe',
|
||||
monitorlaunchpath=r'C:\Vendor\app.exe')
|
||||
|
||||
response = _update(client, auth_headers, app.appid,
|
||||
monitorprocessname='')
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'process name' in _message(response)
|
||||
|
||||
|
||||
def test_whitespace_does_not_count_as_a_process_name(client, auth_headers):
|
||||
response = _create(client, auth_headers, ismonitorable=True,
|
||||
monitorprocessname=' ',
|
||||
monitorlaunchpath=r'C:\Vendor\app.exe')
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'process name' in _message(response)
|
||||
|
||||
|
||||
def test_unticking_the_box_releases_the_requirement(client, auth_headers, db):
|
||||
# Turning monitoring off must not be blocked by the fields it needed while
|
||||
# it was on, or an operator cannot undo a mistake without a round trip.
|
||||
app = _app(db, name='Live Monitor', ismonitorable=True,
|
||||
monitorprocessname='app.exe',
|
||||
monitorlaunchpath=r'C:\Vendor\app.exe')
|
||||
|
||||
response = _update(client, auth_headers, app.appid, ismonitorable=False,
|
||||
monitorprocessname='', monitorlaunchpath='')
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert Application.query.get(app.appid).ismonitorable is False
|
||||
110
tests/test_core/test_application_required_policy.py
Normal file
110
tests/test_core/test_application_required_policy.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for the acknowledgement gate on "Required on all PCs".
|
||||
|
||||
Ticking that box is not a reporting flag. It generates a configuration policy
|
||||
that installs the application on every non-server endpoint it matches, so the
|
||||
save is a fleet-wide deployment. The gate exists so nobody triggers one by
|
||||
tabbing through a form.
|
||||
|
||||
The gate lives in the API rather than only in the form on purpose, and that is
|
||||
most of what is pinned here: a confirmation dialog that can be sidestepped by
|
||||
POSTing directly is decoration, not a safeguard. The other half is that it
|
||||
fires only on the transition INTO the required state, since re-saving an app
|
||||
that already has its policy deploys nothing new.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
|
||||
def _app(db, name='Test App', isrequired=False):
|
||||
app = Application(appname=name, isrequired=isrequired)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def test_create_marked_required_is_refused_without_acknowledgement(
|
||||
client, db, auth_headers):
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': 'Fleet App', 'isrequired': True},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'install' in message.lower()
|
||||
assert Application.query.filter_by(appname='Fleet App').first() is None
|
||||
|
||||
|
||||
def test_create_marked_required_succeeds_once_acknowledged(
|
||||
client, db, auth_headers):
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': 'Fleet App', 'isrequired': True,
|
||||
'acknowledgepolicy': True},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 201, response.get_json()
|
||||
assert Application.query.filter_by(appname='Fleet App').first().isrequired
|
||||
|
||||
|
||||
def test_update_into_required_is_refused_without_acknowledgement(
|
||||
client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.put(f'/api/applications/{app.appid}',
|
||||
json={'isrequired': True}, headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
# The refusal must leave the flag alone, not half-apply the change.
|
||||
assert db.session.get(Application, app.appid).isrequired is False
|
||||
|
||||
|
||||
def test_update_into_required_succeeds_once_acknowledged(
|
||||
client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.put(f'/api/applications/{app.appid}',
|
||||
json={'isrequired': True, 'acknowledgepolicy': True},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert db.session.get(Application, app.appid).isrequired is True
|
||||
|
||||
|
||||
def test_resaving_an_already_required_app_needs_no_acknowledgement(
|
||||
client, db, auth_headers):
|
||||
# The policy already exists, so this save deploys nothing new. Prompting
|
||||
# again would train people to click through the warning that matters.
|
||||
app = _app(db, isrequired=True)
|
||||
|
||||
response = client.put(f'/api/applications/{app.appid}',
|
||||
json={'isrequired': True,
|
||||
'appdescription': 'Edited'},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
|
||||
def test_clearing_required_needs_no_acknowledgement(client, db, auth_headers):
|
||||
# Unticking withdraws the policy. It never installs anything, so there is
|
||||
# nothing to warn about.
|
||||
app = _app(db, isrequired=True)
|
||||
|
||||
response = client.put(f'/api/applications/{app.appid}',
|
||||
json={'isrequired': False}, headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert db.session.get(Application, app.appid).isrequired is False
|
||||
|
||||
|
||||
def test_acknowledgement_flag_is_not_persisted_to_the_application(
|
||||
client, db, auth_headers):
|
||||
# It is a control flag for one request, not a column. If it ever reached
|
||||
# the model it would be a stored "yes" that suppresses future warnings.
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': 'Fleet App', 'isrequired': True,
|
||||
'acknowledgepolicy': True},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 201, response.get_json()
|
||||
app = Application.query.filter_by(appname='Fleet App').first()
|
||||
assert not hasattr(app, 'acknowledgepolicy')
|
||||
assert 'acknowledgepolicy' not in response.get_json()['data']
|
||||
109
tests/test_core/test_application_required_policy_sync.py
Normal file
109
tests/test_core/test_application_required_policy_sync.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""The generated "required on all PCs" policy follows the app by appid.
|
||||
|
||||
The generated policy name embeds appname, so a name lookup orphaned the policy
|
||||
on rename. These pin the appid lookup, rename+untick in one PUT, delete handling
|
||||
of a customised ('kept') policy and of other policies naming the appid, and the
|
||||
truthful note that the generated policy is not attached to a business policy.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
|
||||
def _policies():
|
||||
from plugins.computers.models import ConfigurationPolicy
|
||||
return ConfigurationPolicy.query
|
||||
|
||||
|
||||
def _create_required(client, headers, name='Fleet App'):
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': name, 'isrequired': True,
|
||||
'acknowledgepolicy': True},
|
||||
headers=headers)
|
||||
assert response.status_code == 201, response.get_json()
|
||||
return response.get_json()['data']
|
||||
|
||||
|
||||
def test_created_response_says_the_policy_is_not_attached(client, db, auth_headers):
|
||||
data = _create_required(client, auth_headers)
|
||||
assert data['policyresult'] == 'created'
|
||||
assert 'business policy' in data['policynote']
|
||||
|
||||
|
||||
def test_ack_message_mentions_business_policy(client, db, auth_headers):
|
||||
response = client.post('/api/applications',
|
||||
json={'appname': 'Unacked', 'isrequired': True},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
assert 'business policy' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_rename_keeps_the_policy_and_renames_it(client, db, auth_headers):
|
||||
data = _create_required(client, auth_headers, 'Old Name')
|
||||
|
||||
response = client.put(f"/api/applications/{data['appid']}",
|
||||
json={'appname': 'New Name'}, headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
names = [policy.name for policy in _policies().all()]
|
||||
assert names == ['Required app: New Name']
|
||||
|
||||
|
||||
def test_untick_after_rename_removes_the_policy(client, db, auth_headers):
|
||||
data = _create_required(client, auth_headers, 'Before')
|
||||
from plugins.computers.models import ConfigurationPolicy
|
||||
# Simulate a rename that bypassed the sync (older data): name is stale.
|
||||
policy = ConfigurationPolicy.query.one()
|
||||
application = db.session.get(Application, data['appid'])
|
||||
application.appname = 'After'
|
||||
db.session.commit()
|
||||
assert policy.name == 'Required app: Before'
|
||||
|
||||
response = client.put(f"/api/applications/{data['appid']}",
|
||||
json={'isrequired': False}, headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['policyresult'] == 'removed'
|
||||
assert _policies().count() == 0
|
||||
|
||||
|
||||
def test_rename_and_untick_in_one_put_removes_the_policy(client, db, auth_headers):
|
||||
data = _create_required(client, auth_headers, 'Combo')
|
||||
|
||||
response = client.put(f"/api/applications/{data['appid']}",
|
||||
json={'appname': 'Combo Renamed', 'isrequired': False},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['policyresult'] == 'removed'
|
||||
assert _policies().count() == 0
|
||||
|
||||
|
||||
def test_delete_deactivates_kept_and_referencing_policies(client, db, auth_headers):
|
||||
from plugins.computers.models import (ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
POLICY_SOURCE_LOCAL)
|
||||
|
||||
data = _create_required(client, auth_headers, 'Doomed')
|
||||
generated = ConfigurationPolicy.query.one()
|
||||
generated.source = POLICY_SOURCE_LOCAL # admin customised it
|
||||
handmade = ConfigurationPolicy(name='Hand made push', scopejson=None)
|
||||
db.session.add(handmade)
|
||||
db.session.flush()
|
||||
db.session.add(ConfigurationPolicyAction(
|
||||
configurationpolicyid=handmade.configurationpolicyid,
|
||||
action='update_app', params={'appid': data['appid']}))
|
||||
unrelated = ConfigurationPolicy(name='Unrelated', scopejson=None)
|
||||
db.session.add(unrelated)
|
||||
db.session.commit()
|
||||
|
||||
response = client.delete(f"/api/applications/{data['appid']}",
|
||||
json={'confirm': 'OK'}, headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()
|
||||
assert body['data']['policyresult'] == 'kept'
|
||||
assert sorted(body['data']['deactivatedpolicies']) == \
|
||||
['Hand made push', 'Required app: Doomed']
|
||||
db.session.expire_all()
|
||||
states = {policy.name: policy.isactive for policy in _policies().all()}
|
||||
assert states == {'Required app: Doomed': False, 'Hand made push': False,
|
||||
'Unrelated': True}
|
||||
196
tests/test_core/test_application_revert.py
Normal file
196
tests/test_core/test_application_revert.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Tests for reverting an application back to a plain catalogue record.
|
||||
|
||||
Revert is the middle ground between editing and deleting: it withdraws the
|
||||
generated policy, drops the version rows and removes the uploaded executables,
|
||||
while the application itself stays in the catalogue.
|
||||
|
||||
The line these tests defend is what revert must NOT touch. The warning offers
|
||||
to remove "policy associations and uploaded executables" and nothing else, so
|
||||
the image, the name, the description and the knowledgebase links all have to
|
||||
survive. An operator who reverts to clean up an installer should not discover
|
||||
afterwards that the record lost its icon.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Application, AppVersion
|
||||
|
||||
from plugins.computers.models import ConfigurationPolicy
|
||||
from plugins.computers.services.apppolicy import auto_policy_name
|
||||
|
||||
|
||||
def _app(db, name='Test App', **kwargs):
|
||||
app = Application(appname=name, **kwargs)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _revert(client, headers, appid, confirm='OK'):
|
||||
return client.post(f'/api/applications/{appid}/revert',
|
||||
json={'confirm': confirm} if confirm is not None else {},
|
||||
headers=headers)
|
||||
|
||||
|
||||
def _upload_package(client, headers, appid, name='setup.exe', data=b'MZfake'):
|
||||
return client.post(f'/api/applications/{appid}/package',
|
||||
data={'file': (io.BytesIO(data), name)},
|
||||
content_type='multipart/form-data',
|
||||
headers=headers)
|
||||
|
||||
|
||||
def _upload_image(client, headers, appid):
|
||||
# A one-pixel PNG; the route only needs a plausible image file.
|
||||
png = (b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00'
|
||||
b'\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc'
|
||||
b'\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`'
|
||||
b'\x82')
|
||||
return client.post(f'/api/applications/{appid}/image',
|
||||
data={'file': (io.BytesIO(png), 'icon.png')},
|
||||
content_type='multipart/form-data',
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_revert_keeps_the_application_row(client, db, auth_headers):
|
||||
# The whole point of revert over delete: the record survives.
|
||||
app = _app(db, name='DeviceLogger')
|
||||
appid = app.appid
|
||||
|
||||
response = _revert(client, auth_headers, appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
survivor = db.session.get(Application, appid)
|
||||
assert survivor is not None
|
||||
assert survivor.appname == 'DeviceLogger'
|
||||
|
||||
|
||||
def test_revert_keeps_the_image(client, db, auth_headers):
|
||||
# The warning offers to remove policy associations and executables. It says
|
||||
# nothing about the icon, and a record kept on purpose should keep it.
|
||||
app = _app(db)
|
||||
appid = app.appid
|
||||
assert _upload_image(client, auth_headers, appid).status_code == 200
|
||||
|
||||
stored = db.session.get(Application, appid).image
|
||||
assert stored
|
||||
|
||||
response = _revert(client, auth_headers, appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert db.session.get(Application, appid).image == stored
|
||||
|
||||
from shopdb.core.api.applications import _appimage_dir
|
||||
surviving = [name for name in os.listdir(_appimage_dir())
|
||||
if name.startswith(f'application-{appid}.')]
|
||||
assert surviving, 'the image file was deleted from disk'
|
||||
|
||||
|
||||
def test_revert_removes_the_uploaded_executables(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
appid = app.appid
|
||||
assert _upload_package(client, auth_headers, appid).status_code == 200
|
||||
|
||||
from shopdb.core.api.applications import _apppackage_dir
|
||||
packagedir = os.path.join(_apppackage_dir(), str(appid))
|
||||
assert os.listdir(packagedir) == ['setup.exe']
|
||||
|
||||
response = _revert(client, auth_headers, appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert not os.path.isdir(packagedir)
|
||||
reverted = db.session.get(Application, appid)
|
||||
assert reverted.installpath is None
|
||||
assert reverted.installersha256 is None
|
||||
|
||||
|
||||
def test_revert_leaves_an_operator_typed_install_path_alone(client, db,
|
||||
auth_headers):
|
||||
# A UNC path points at the site's own share, not our upload directory, and
|
||||
# is not ours to wipe.
|
||||
share = r'\\fileserver\software\DeviceLogger\setup.exe'
|
||||
app = _app(db, installpath=share)
|
||||
|
||||
response = _revert(client, auth_headers, app.appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert db.session.get(Application, app.appid).installpath == share
|
||||
|
||||
|
||||
def test_revert_takes_the_versions_with_it(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
appid = app.appid
|
||||
db.session.add(AppVersion(appid=appid, version='1.0.0.0'))
|
||||
db.session.commit()
|
||||
|
||||
response = _revert(client, auth_headers, appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert AppVersion.query.filter_by(appid=appid).count() == 0
|
||||
|
||||
|
||||
def test_revert_withdraws_the_generated_policy(client, db, auth_headers):
|
||||
created = client.post('/api/applications',
|
||||
json={'appname': 'Fleet App', 'isrequired': True,
|
||||
'acknowledgepolicy': True},
|
||||
headers=auth_headers)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
app = Application.query.filter_by(appname='Fleet App').first()
|
||||
assert ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first() is not None
|
||||
|
||||
response = _revert(client, auth_headers, app.appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert ConfigurationPolicy.query.filter_by(
|
||||
name=auto_policy_name(app)).first() is None
|
||||
assert db.session.get(Application, app.appid).isrequired is False
|
||||
|
||||
|
||||
def test_revert_keeps_the_inventory_links(client, db, auth_headers):
|
||||
# A machine still has the software installed whether or not we ship an
|
||||
# installer for it. Dropping these would make the fleet look clean.
|
||||
from plugins.computers.models import Computer, ComputerInstalledApp
|
||||
|
||||
app = _app(db)
|
||||
computer = Computer(assetid='PC-001')
|
||||
db.session.add(computer)
|
||||
db.session.commit()
|
||||
db.session.add(ComputerInstalledApp(computerid=computer.computerid,
|
||||
appid=app.appid))
|
||||
db.session.commit()
|
||||
|
||||
response = _revert(client, auth_headers, app.appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert ComputerInstalledApp.query.filter_by(appid=app.appid).count() == 1
|
||||
|
||||
|
||||
def test_revert_is_refused_without_the_typed_confirmation(client, db,
|
||||
auth_headers):
|
||||
app = _app(db)
|
||||
assert _upload_package(client, auth_headers, app.appid).status_code == 200
|
||||
|
||||
response = _revert(client, auth_headers, app.appid, confirm=None)
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
# Nothing may have been removed on a refused call.
|
||||
from shopdb.core.api.applications import _apppackage_dir
|
||||
assert os.path.isdir(os.path.join(_apppackage_dir(), str(app.appid)))
|
||||
|
||||
|
||||
def test_revert_is_refused_when_the_confirmation_is_wrong_case(client, db,
|
||||
auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = _revert(client, auth_headers, app.appid, confirm='ok')
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
|
||||
def test_revert_requires_the_edit_permission(client, db, member_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = _revert(client, member_headers, app.appid)
|
||||
|
||||
assert response.status_code in (401, 403), response.get_json()
|
||||
@@ -77,11 +77,15 @@ def test_installer_upload_sets_the_install_path(client, db, auth_headers):
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()['data']
|
||||
# The installer keeps the name the vendor shipped, namespaced by
|
||||
# application id so a generic "setup.msi" from two vendors cannot collide.
|
||||
# The old application-<id>.msi told the operator nothing and made a
|
||||
# re-import look like new software.
|
||||
assert body['installpath'] == \
|
||||
f'/api/applications/package/application-{app.appid}.msi'
|
||||
# The name the vendor shipped is reported back, since the stored copy is
|
||||
# renamed and the uploader needs to recognise what they sent.
|
||||
f'/api/applications/package/{app.appid}/setup.msi'
|
||||
assert body['uploadedfilename'] == 'setup.msi'
|
||||
# An MSI is not a PE file, so there is no version resource to read.
|
||||
assert body['detectedversion'] is None
|
||||
|
||||
|
||||
def test_installer_download_needs_a_token(client, db, auth_headers):
|
||||
@@ -90,7 +94,7 @@ def test_installer_download_needs_a_token(client, db, auth_headers):
|
||||
client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = f'/api/applications/package/application-{app.appid}.msi'
|
||||
path = f'/api/applications/package/{app.appid}/setup.msi'
|
||||
|
||||
assert client.get(path).status_code == 401
|
||||
|
||||
|
||||
96
tests/test_core/test_application_version_detection.py
Normal file
96
tests/test_core/test_application_version_detection.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Tests for reading an installer's version at upload time.
|
||||
|
||||
Duplicate application and version rows were being created because an operator
|
||||
imported the same installer twice: nothing on screen told them the file already
|
||||
matched a version in the catalogue. Reading the version out of the installer at
|
||||
upload closes that, and saying so when there is no version to read closes the
|
||||
rest of it.
|
||||
|
||||
Building a real PE with a RT_VERSION resource inline is impractical (see the
|
||||
agentstore unit tests, which cover the parser itself), so these tests
|
||||
monkeypatch read_pe_file_version and focus on the wiring: that the upload route
|
||||
calls it, reports what it found, and reports None rather than guessing.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
from plugins.endpoints.api import agentstore
|
||||
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 128
|
||||
|
||||
|
||||
def _app(db, name='Test App'):
|
||||
app = Application(appname=name)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _upload(client, headers, appid, name='setup.exe', data=_PE_BYTES):
|
||||
return client.post(f'/api/applications/{appid}/package',
|
||||
data={'file': (io.BytesIO(data), name)},
|
||||
content_type='multipart/form-data',
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_upload_reports_the_detected_version(client, db, auth_headers,
|
||||
monkeypatch):
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version',
|
||||
lambda data: '2.4.1.0')
|
||||
app = _app(db)
|
||||
|
||||
response = _upload(client, auth_headers, app.appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['detectedversion'] == '2.4.1.0'
|
||||
|
||||
|
||||
def test_upload_reports_none_when_there_is_no_version_resource(
|
||||
client, db, auth_headers, monkeypatch):
|
||||
# None is the signal the form uses to ask the operator for a version. It
|
||||
# must not be substituted with a placeholder, or the duplicate-import
|
||||
# problem comes straight back.
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: None)
|
||||
app = _app(db)
|
||||
|
||||
response = _upload(client, auth_headers, app.appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['detectedversion'] is None
|
||||
|
||||
|
||||
def test_detection_does_not_consume_the_stream(client, db, auth_headers,
|
||||
monkeypatch):
|
||||
# Version detection reads the upload before it is saved. If it did not
|
||||
# rewind, the stored installer would be truncated or empty - which would
|
||||
# not fail any assertion about the version, only corrupt the payload.
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version',
|
||||
lambda data: '1.0.0.0')
|
||||
app = _app(db)
|
||||
|
||||
assert _upload(client, auth_headers, app.appid).status_code == 200
|
||||
|
||||
download = client.get(
|
||||
f'/api/applications/package/{app.appid}/setup.exe',
|
||||
headers=auth_headers)
|
||||
assert download.status_code == 200
|
||||
assert download.data == _PE_BYTES
|
||||
|
||||
|
||||
def test_detection_failure_does_not_fail_the_upload(client, db, auth_headers,
|
||||
monkeypatch):
|
||||
# A version is a convenience; losing it must not cost the operator the
|
||||
# upload of a 500MB installer.
|
||||
def _boom(data):
|
||||
raise ValueError('malformed resource directory')
|
||||
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version', _boom)
|
||||
app = _app(db)
|
||||
|
||||
response = _upload(client, auth_headers, app.appid)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['detectedversion'] is None
|
||||
79
tests/test_core/test_asset_type_descriptor_enforcement.py
Normal file
79
tests/test_core/test_asset_type_descriptor_enforcement.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""A descriptor's edit_permission must be real, not advisory.
|
||||
|
||||
`get_asset_type_descriptor` declares `edit_permission`, and
|
||||
/api/pluginui/asset-type-descriptors hands it to the Asset Type Hub, which
|
||||
renders generic CRUD against the declared `subtype_endpoint`. Nothing in the
|
||||
contract makes the plugin actually enforce that permission on its own routes.
|
||||
|
||||
Every shipped plugin does today. The risk is structural: a plugin author can
|
||||
declare a permission, forget the decorator, and ship a UI that looks locked over
|
||||
an API that is open - and the contract invites exactly that mistake. So walk the
|
||||
descriptors the API really returns and prove each named endpoint refuses a
|
||||
caller who holds nothing.
|
||||
|
||||
A signed-in caller with no permissions, not an anonymous one: anonymous would
|
||||
pass on the 401 and prove nothing about authorization.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
DESCRIPTORS_URL = '/api/pluginui/asset-type-descriptors'
|
||||
|
||||
# The write methods the Asset Type Hub drives through subtype_endpoint. GET is
|
||||
# not here on purpose: a list may legitimately need only a read permission,
|
||||
# while edit_permission is about changing a subtype.
|
||||
WRITE_CALLS = (
|
||||
('post', '{endpoint}'),
|
||||
('put', '{endpoint}/1'),
|
||||
('delete', '{endpoint}/1'),
|
||||
)
|
||||
|
||||
|
||||
def _descriptors(client, auth_headers):
|
||||
response = client.get(DESCRIPTORS_URL, headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
return response.get_json()['data']
|
||||
|
||||
|
||||
def test_there_are_descriptors_to_check(client, db, auth_headers):
|
||||
"""A silently empty list would make the sweep below vacuous."""
|
||||
assert _descriptors(client, auth_headers), (
|
||||
'no plugin returned an asset type descriptor; the enforcement sweep '
|
||||
'below would pass without checking anything')
|
||||
|
||||
|
||||
def test_every_descriptor_endpoint_refuses_an_unpermissioned_caller(
|
||||
client, db, auth_headers, member_headers):
|
||||
"""The permission a descriptor advertises has to be the one the API asks
|
||||
for. A route that answers anything but 403 here is a lock drawn on a door
|
||||
that is not locked."""
|
||||
open_routes = []
|
||||
for descriptor in _descriptors(client, auth_headers):
|
||||
endpoint = '/api' + descriptor['subtype_endpoint']
|
||||
for method, template in WRITE_CALLS:
|
||||
path = template.format(endpoint=endpoint)
|
||||
call = getattr(client, method)
|
||||
response = call(path, json={'name': 'x'}, headers=member_headers)
|
||||
if response.status_code != 403:
|
||||
open_routes.append(
|
||||
'{0} declares {1} but {2} {3} answered {4}'.format(
|
||||
descriptor.get('plugin'),
|
||||
descriptor.get('edit_permission'),
|
||||
method.upper(), path, response.status_code))
|
||||
|
||||
assert not open_routes, (
|
||||
'These descriptors advertise protection the API does not enforce. '
|
||||
'Decorate the subtype routes with @require_permission for the '
|
||||
'permission the descriptor names:\n ' + '\n '.join(open_routes))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field', ['assettype', 'subtype_endpoint',
|
||||
'edit_permission'])
|
||||
def test_every_descriptor_names_what_the_sweep_needs(client, db, auth_headers,
|
||||
field):
|
||||
"""The sweep above can only check a descriptor that says what to check."""
|
||||
for descriptor in _descriptors(client, auth_headers):
|
||||
assert descriptor.get(field), (
|
||||
'{0} returned a descriptor with no {1}'.format(
|
||||
descriptor.get('plugin'), field))
|
||||
@@ -40,49 +40,7 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
|
||||
# The apitokens create/update/revoke endpoints are NOT exempt: they now require
|
||||
# the apitokens.create permission, so a role-less member gets the 403 this sweep
|
||||
# asserts (ownership is still enforced inside the handler for non-admins).
|
||||
# geenforce.post_report - GE-Enforce client ingest, authenticated by a
|
||||
# geenforce.report managed service token (X-API-Key/Bearer), not JWT. Same
|
||||
# shape as the exempt collector blueprint; the geenforce admin endpoints in
|
||||
# the same blueprint are JWT+permission gated and ARE swept.
|
||||
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
'geenforce.post_report',
|
||||
# Kiosk write. No JWT - the people at the bin have no
|
||||
# account - but no longer open: it needs the site's kiosk
|
||||
# flag AND the per-device token (X-Kiosk-Token), and 404s
|
||||
# without them. The badge only attributes the ledger row.
|
||||
# The old "physically rate-limited by being a touch screen"
|
||||
# justification did not survive a scanner: bare digits
|
||||
# enumerated the catalogue and the 422/200 split was an
|
||||
# oracle for real employee numbers.
|
||||
'printedparts.kiosk_take',
|
||||
# Deliberately open kiosk writes. NOT the same bar as the
|
||||
# parts kiosk, and the difference is the point: these are
|
||||
# additionally behind usb_kiosk_enabled, which the parts
|
||||
# kiosk has no equivalent of, so a site that never turns
|
||||
# them on serves 404s where printedparts serves a live
|
||||
# write. They can ONLY move a USB device between
|
||||
# checked-out and checked-in, and only when a site has set
|
||||
# usb_kiosk_enabled (off by default - every one of these is
|
||||
# a 404 until then). Identity is a scanned badge resolved
|
||||
# server-side, and a badge that resolves to no employee is
|
||||
# refused rather than recorded, so nothing here can create
|
||||
# an untraceable checkout. The people who take drives stand
|
||||
# at the cabinet and mostly have no account, and both modes
|
||||
# already attributed the action to the badge rather than to
|
||||
# the signed-in user - the login was gating the screen, not
|
||||
# the record. Covered by tests/test_plugins/test_usb_kiosk.py.
|
||||
'usb.kiosk_checkout',
|
||||
'usb.kiosk_checkin',
|
||||
# The kiosk's two list endpoints. POSTs that CHANGE NOTHING:
|
||||
# they are reads, shaped as POSTs so the badge stays out of
|
||||
# query strings and proxy access logs. Same gate as the
|
||||
# writes above - 404 unless usb_kiosk_enabled, and they name
|
||||
# no drive until the badge resolves to a real person. The
|
||||
# drives have no barcode, so a list is the only way to pick
|
||||
# one; kiosk_others additionally 404s when returning for
|
||||
# somebody else is switched off.
|
||||
'usb.kiosk_session',
|
||||
'usb.kiosk_others',
|
||||
# Redeeming a shared secret. The recipient usually has no
|
||||
# account here, so the unguessable 128-bit token IS the
|
||||
# authorisation - gating this on a role would make the
|
||||
@@ -110,21 +68,31 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||
# collector key it already holds. The human-facing read and
|
||||
# image-serve routes ARE permission-gated on endpoints.view
|
||||
# and ARE swept.
|
||||
'endpoints.upload_operator_report'}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _selfhosted_directory_modes(db):
|
||||
# The usb/employees plugins default to EXTERNAL directory mode when no
|
||||
# setting row exists, so exercising their handlers reaches for a MySQL
|
||||
# server that exists on the dev box but not in CI. Pin selfhosted mode
|
||||
# so the sweep is deterministic everywhere.
|
||||
from shopdb.core.models import Setting
|
||||
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.add(Setting(key='employee_directory_mode', value='selfhosted',
|
||||
valuetype='string', category='site'))
|
||||
db.session.commit()
|
||||
'endpoints.upload_operator_report',
|
||||
# Agent-facing fleet alert acknowledgement. Authenticated by
|
||||
# the endpoints collector API key (same shape as the exempt
|
||||
# collector blueprint), not JWT+role: the operator dismissing
|
||||
# an alert on a shop-floor bay has no user session, and the
|
||||
# tray hands the acknowledgement to the service, which posts
|
||||
# it on the collector key it already holds. The handler binds
|
||||
# the acknowledgement to the asset the reported hostname
|
||||
# resolves to, so the key cannot ack another machine's alert.
|
||||
# AUTHORING a fleet alert is the actual authority and stays
|
||||
# permission-gated in the notifications plugin.
|
||||
'endpoints.acknowledge_fleet_alert',
|
||||
# Agent enrollment. A PC with no credential asks for one on
|
||||
# the shared collector key (same shape as the exempt
|
||||
# collector blueprint); with the shared key switched off it
|
||||
# takes no auth and only collects a credential an admin
|
||||
# already approved for that hostname, serial and nonce.
|
||||
# Approving, rejecting and revoking ARE admin-gated and
|
||||
# ARE swept.
|
||||
'endpoints.enroll_endpoint',
|
||||
# Agent telemetry ingest under the endpoints prefix. The
|
||||
# same handler as POST /api/collector/endpoints, whose
|
||||
# blueprint is exempt: authenticated by the per-PC
|
||||
# credential or the shared collector key.
|
||||
'endpoints.agent_collect'}
|
||||
|
||||
|
||||
def _fill_url(rule):
|
||||
|
||||
@@ -49,17 +49,13 @@ SKIP_PREFIXES = (
|
||||
'/api/backups/run', # kicks off work rather than reading
|
||||
)
|
||||
|
||||
# Skipped by EXACT path, not prefix, so the rest of the plugin stays covered.
|
||||
# Skipped by EXACT path, not prefix, so the rest of a plugin stays covered.
|
||||
# A route belongs here only when it reaches an EXTERNAL database: its 500 is
|
||||
# then "no such database in the test environment", a different fault from the
|
||||
# one this gate is about.
|
||||
SKIP_EXACT = {
|
||||
# These four reach the EXTERNAL cmmc_usb database. Their 500 here is "no
|
||||
# such database in the test environment", a different fault from the one
|
||||
# this gate is about, and the list is the complete set - every usb GET
|
||||
# route whose handler calls cmmc_usb_connection. The self-hosted USB
|
||||
# surface is covered by test_usb_kiosk.py.
|
||||
'/api/usb',
|
||||
'/api/usb/checkouts',
|
||||
'/api/usb/checkouts/active',
|
||||
'/api/usb/reports/audit',
|
||||
'/api/employees/search',
|
||||
'/api/employees/lookup',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_per_plugin_key_overrides_shared(client, db, app, computer_assettype):
|
||||
|
||||
def test_complete_asset_payload_maps_enrollment_data(client, db, collector_key,
|
||||
computer_assettype):
|
||||
"""A GE-Enforce-shaped payload maps machinenumber/pctype/vendor/model/os."""
|
||||
"""A fully populated enrollment payload maps machinenumber/pctype/vendor/model/os."""
|
||||
from shopdb.core.models import OperatingSystem
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
|
||||
@@ -337,107 +337,13 @@ def test_env_key_still_works_alongside_tokens(client, db, collector_key,
|
||||
|
||||
|
||||
# -- metrology measuring-tool auto-link ------------------------------------
|
||||
# A CMM/Keyence/Genspect/wax-trace PC stays a shopfloor PC, but the collector
|
||||
# creates the attached measuring instrument as a MeasuringTool asset and links
|
||||
# the PC to it with a directional 'controls' relationship.
|
||||
|
||||
@pytest.fixture
|
||||
def measuringtool_env(db):
|
||||
"""Seed the measuring_tool asset type + 'controls' relationship type the
|
||||
metrology collector path links through."""
|
||||
from shopdb.core.models import AssetType, RelationshipType
|
||||
db.session.add(AssetType(assettype='measuring_tool',
|
||||
pluginname='measuringtools',
|
||||
tablename='measuringtools',
|
||||
description='Metrology instruments'))
|
||||
db.session.add(RelationshipType(relationshiptype='controls',
|
||||
description='Operational authority',
|
||||
isdirectional=True))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_metrology_pctype_creates_and_links_measuringtool(
|
||||
client, db, collector_key, computer_assettype, measuringtool_env):
|
||||
"""A CMM PC gets a MeasuringTool asset + controls link; the CMM tool type
|
||||
is auto-created (it is not in the measuringtools starter seed)."""
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.measuringtools.models import MeasuringTool, MeasuringToolType
|
||||
from shopdb.core.models import AssetRelationship
|
||||
|
||||
payload = {'hostname': 'WJCMM01', 'pctype': 'gea-shopfloor-cmm'}
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['measuringtoollinkcount'] == 1
|
||||
link = data['measuringtoollinks'][0]
|
||||
assert link['relationshiptype'] == 'controls'
|
||||
assert link['measuringtooltype'] == 'CMM'
|
||||
|
||||
with client.application.app_context():
|
||||
tools = MeasuringTool.query.all()
|
||||
assert len(tools) == 1
|
||||
assert MeasuringToolType.query.filter_by(name='CMM').first() is not None
|
||||
pc = Computer.query.filter(Computer.hostname.ilike('WJCMM01')).first()
|
||||
rels = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, label='collector:measuringtool').all()
|
||||
assert len(rels) == 1
|
||||
assert rels[0].targetassetid == tools[0].assetid
|
||||
assert rels[0].isactive
|
||||
|
||||
|
||||
def test_metrology_link_idempotent(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""Re-pushing the same metrology PC does not duplicate the tool asset."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
|
||||
payload = {'hostname': 'WJCMM02', 'pctype': 'gea-shopfloor-cmm'}
|
||||
for _ in range(2):
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
with client.application.app_context():
|
||||
assert MeasuringTool.query.count() == 1
|
||||
|
||||
|
||||
def test_nonmetrology_pctype_makes_no_tool(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""A plain shopfloor PC gets no measuring-tool asset."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
|
||||
payload = {'hostname': 'WJPC900', 'pctype': 'gea-shopfloor-collections'}
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
|
||||
with client.application.app_context():
|
||||
assert MeasuringTool.query.count() == 0
|
||||
|
||||
|
||||
def test_repurposed_pc_archives_tool_link(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""A metrology PC re-imaged to a non-metrology type has its collector tool
|
||||
link archived (tool asset kept, relationship deactivated)."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
from shopdb.core.models import AssetRelationship
|
||||
|
||||
host = {'hostname': 'WJCMM03'}
|
||||
client.post('/api/collector/computers',
|
||||
json={**host, 'pctype': 'gea-shopfloor-cmm'},
|
||||
headers={'X-API-Key': KEY})
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={**host, 'pctype': 'gea-shopfloor-collections'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
|
||||
with client.application.app_context():
|
||||
# asset preserved (may carry calibration history), link deactivated
|
||||
assert MeasuringTool.query.count() == 1
|
||||
rels = AssetRelationship.query.filter_by(
|
||||
label='collector:measuringtool').all()
|
||||
assert len(rels) == 1
|
||||
assert rels[0].isactive is False
|
||||
|
||||
# A CMM/Keyence/wax-trace PC used to mint a MeasuringTool asset here and link
|
||||
# the PC to it. No measuringtools plugin ships on this line, so the collector's
|
||||
# guarded import fails, the link is skipped with a warning, and there is nothing
|
||||
# left to assert. The pc-type map that DRIVES it is still core to the computers
|
||||
# plugin and is covered by test_the_metrology_map_still_answers_through_the_shim
|
||||
# below - so a site that adds its own measuringtools plugin still gets a tested
|
||||
# map to link through.
|
||||
|
||||
# =============================================================================
|
||||
# Remote-access protocol sync (RealVNC and friends)
|
||||
@@ -970,7 +876,7 @@ def test_a_marker_pc_does_not_claim_the_operation_directly(
|
||||
|
||||
def test_reporting_again_reuses_the_marker(
|
||||
client, db, collector_key, computer_assettype, operation_0615):
|
||||
"""GE-Enforce posts every cycle; a marker must not be minted each time."""
|
||||
"""A fleet reporter posts every cycle; a marker must not be minted each time."""
|
||||
from shopdb.core.models import Asset
|
||||
for _ in range(3):
|
||||
client.post('/api/collector/computers', json=_marker('MARKERPC1'),
|
||||
|
||||
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
|
||||
@@ -71,8 +71,10 @@ def test_a_viewall_target_is_one_of_the_known_pages(app):
|
||||
browser instead of the API."""
|
||||
known = {
|
||||
'/pcs', '/machines', '/printers', '/network', '/notifications',
|
||||
'/geenforce', '/reports/toner', '/reports/warranty',
|
||||
'/reports/toner', '/reports/warranty',
|
||||
'/reports/pc-relationships', '/reports/calibration', '/reports',
|
||||
# plugins/endpoints/frontend/routes.js, readable by endpoints.fleet
|
||||
'/settings/endpoints/enrollment',
|
||||
}
|
||||
with app.app_context():
|
||||
for pluginname, card in _declared(app):
|
||||
|
||||
@@ -175,8 +175,8 @@ def test_unmapped_ip_display_role_null(client, db):
|
||||
# --- role vocabulary -------------------------------------------------------
|
||||
#
|
||||
# The role names are not ours to choose: they are the literal values a person
|
||||
# types into C:\Enrollment\display-type.txt on the kiosk, which the GE-Enforce
|
||||
# dispatcher reads. These tests exist because core and the kiosks each used to
|
||||
# types into C:\Enrollment\display-type.txt on the display PC, which its
|
||||
# launcher reads. These tests exist because core and the displays each used to
|
||||
# hold their own map, and a display could report a role core could not store.
|
||||
|
||||
def test_display_roles_are_the_kiosk_vocabulary():
|
||||
@@ -188,13 +188,6 @@ def test_display_roles_are_the_kiosk_vocabulary():
|
||||
}
|
||||
|
||||
|
||||
def test_geenforce_display_targets_are_core_roles():
|
||||
"""The display scope must not carry a second copy of the map."""
|
||||
from shopdb.core.models.dashboarddefault import DISPLAY_ROLE_PATHS
|
||||
from plugins.geenforce.seed_display_scope import DISPLAY_TYPE_TARGETS
|
||||
assert DISPLAY_TYPE_TARGETS is DISPLAY_ROLE_PATHS
|
||||
|
||||
|
||||
def test_normalize_display_role_accepts_any_casing():
|
||||
from shopdb.core.models.dashboarddefault import normalize_display_role
|
||||
for value in ('Lobby', 'lobby', 'LOBBY', ' Lobby '):
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Characterization tests for the employee directory endpoints.
|
||||
|
||||
Written before extracting employees into a plugin. The wjf_employees DB is not
|
||||
available under test, so these assert the DB-independent behaviors: validation
|
||||
(400) and graceful failure (500 envelope, not an unhandled crash) when the
|
||||
directory is unreachable. Behavior must be identical as a plugin blueprint.
|
||||
"""
|
||||
|
||||
|
||||
def test_search_requires_min_query(client, db, auth_headers):
|
||||
"""A too-short query is rejected before touching the directory DB."""
|
||||
resp = client.get('/api/employees/search?q=a', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_lookup_requires_numeric_sso(client, db, auth_headers):
|
||||
"""Non-numeric SSO is rejected without touching the directory DB."""
|
||||
resp = client.get('/api/employees/lookup/abc', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_search_degrades_when_directory_unreachable(client, db, auth_headers):
|
||||
"""With the directory DB unavailable, search returns a clean 500 envelope."""
|
||||
resp = client.get('/api/employees/search?q=smith', headers=auth_headers)
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()['status'] == 'error'
|
||||
|
||||
|
||||
# --- a full name has to find the person -------------------------------------
|
||||
#
|
||||
# Reported from the floor: somebody types the employee's whole name into the
|
||||
# notification/recognition picker and presses Enter instead of clicking the
|
||||
# suggestion, and the entry does not pull from the employee database.
|
||||
#
|
||||
# Two causes, one symptom. The search matched the whole query against first
|
||||
# name OR last name, so "Dana Reyes" matched nothing - no single column holds
|
||||
# that string - and with no suggestion to click, the form turned the typed text
|
||||
# into a free-text label with no SSO: no photo, no directory link, and a
|
||||
# recognition the person it is about cannot be found by.
|
||||
|
||||
def _seed(db, first, last, sso):
|
||||
"""One directory employee, in self-hosted mode.
|
||||
|
||||
The mode matters: without it the search goes to the external HR database,
|
||||
which does not exist here and answers 500.
|
||||
"""
|
||||
from shopdb.core.models import Setting
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
|
||||
row = Setting.query.filter_by(key='employee_directory_mode').first()
|
||||
if row is None:
|
||||
db.session.add(Setting(key='employee_directory_mode',
|
||||
value='selfhosted', valuetype='string',
|
||||
category='site'))
|
||||
else:
|
||||
row.value = 'selfhosted'
|
||||
db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_a_full_name_finds_the_employee(client, db, auth_headers):
|
||||
_seed(db, 'Dana', 'Reyes', 990001)
|
||||
|
||||
response = client.get('/api/employees/search?q=Dana%20Reyes',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = response.get_json()['data']
|
||||
assert [str(r['SSO']) for r in rows] == ['990001']
|
||||
|
||||
|
||||
def test_the_words_may_be_in_either_order(client, db, auth_headers):
|
||||
"""A directory that lists "Reyes, Dana" trains people to type it that way."""
|
||||
_seed(db, 'Dana', 'Reyes', 990002)
|
||||
|
||||
rows = client.get('/api/employees/search?q=Reyes%20Dana',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [str(r['SSO']) for r in rows] == ['990002']
|
||||
|
||||
|
||||
def test_every_word_must_match_so_a_full_name_is_not_a_broad_search(
|
||||
client, db, auth_headers):
|
||||
""""Dana Reyes" must not also return Sam Reyes - that is what makes the
|
||||
result unambiguous enough to select automatically."""
|
||||
_seed(db, 'Dana', 'Reyes', 990003)
|
||||
_seed(db, 'Sam', 'Reyes', 990004)
|
||||
|
||||
rows = client.get('/api/employees/search?q=Dana%20Reyes',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [str(r['SSO']) for r in rows] == ['990003']
|
||||
|
||||
|
||||
def test_a_single_word_still_searches_broadly(client, db, auth_headers):
|
||||
"""One word is a search, not a name: it must still find every match."""
|
||||
_seed(db, 'Dana', 'Reyes', 990005)
|
||||
_seed(db, 'Sam', 'Reyes', 990006)
|
||||
|
||||
rows = client.get('/api/employees/search?q=Reyes',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
def test_an_sso_still_resolves(client, db, auth_headers):
|
||||
_seed(db, 'Dana', 'Reyes', 990007)
|
||||
|
||||
rows = client.get('/api/employees/search?q=990007',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [str(r['SSO']) for r in rows] == ['990007']
|
||||
@@ -11,11 +11,6 @@ Covers deliverables of the import-API work:
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
# Import the usb models at collection time so their tables register on the
|
||||
# metadata before the per-test db fixture calls create_all. The usb plugin is
|
||||
# disabled in the dev registry, so its tables would otherwise never be created.
|
||||
from plugins.usb.models import USBDevice, USBCheckout # noqa: F401
|
||||
|
||||
|
||||
IMPORT_HEADER = {'X-Import-Mode': 'true'}
|
||||
LEGACY_CREATED = '2020-01-05 08:30:00'
|
||||
@@ -154,56 +149,3 @@ def test_vendor_exact_lookup_filter(client, db, auth_headers):
|
||||
rows = listed.get_json()['data']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['vendor'] == 'Acme'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backdated USB checkout (selfhosted mode)
|
||||
#
|
||||
# The usb plugin is disabled in the dev registry, so its blueprint may not be
|
||||
# registered. Exercise the selfhosted layer directly inside a request context
|
||||
# carrying the admin JWT + import header, which is what the route delegates to.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_usb_device(db, serialnumber):
|
||||
from plugins.usb.models import USBDevice
|
||||
device = USBDevice(serialnumber=serialnumber, ischeckedout=False, isactive=True)
|
||||
db.session.add(device)
|
||||
db.session.commit()
|
||||
return device
|
||||
|
||||
|
||||
def test_usb_backdated_checkout(app, db, auth_headers):
|
||||
"""Admin + header: a USB checkout records the historical checkouttime."""
|
||||
from plugins.usb.api import selfhosted
|
||||
from plugins.usb.models import USBDevice, USBCheckout
|
||||
|
||||
device = _seed_usb_device(db, 'USB-IMPORT-1')
|
||||
headers = _headers(auth_headers)
|
||||
with app.test_request_context('/api/usb/USB-IMPORT-1/checkout',
|
||||
method='POST', headers=headers):
|
||||
resp = selfhosted.checkout_device(
|
||||
'USB-IMPORT-1',
|
||||
{'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'})
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first()
|
||||
assert row.checkouttime.year == 2020 and row.checkouttime.month == 3
|
||||
reloaded = db.session.get(USBDevice, device.usbdeviceid)
|
||||
assert reloaded.currentcheckoutdate.year == 2020
|
||||
|
||||
|
||||
def test_usb_checkout_ignores_backdate_without_header(app, db, auth_headers):
|
||||
"""No import header: the checkouttime override is ignored, uses now."""
|
||||
from plugins.usb.api import selfhosted
|
||||
from plugins.usb.models import USBDevice, USBCheckout
|
||||
|
||||
device = _seed_usb_device(db, 'USB-IMPORT-2')
|
||||
with app.test_request_context('/api/usb/USB-IMPORT-2/checkout',
|
||||
method='POST', headers=auth_headers):
|
||||
resp = selfhosted.checkout_device(
|
||||
'USB-IMPORT-2',
|
||||
{'badge': '570005354', 'checkouttime': '2020-03-04 09:10:11'})
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
|
||||
row = USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid).first()
|
||||
assert row.checkouttime.year >= 2024 # server now, not the 2020 override
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Every asset list's search box matches the optional identifiers (ADR-001).
|
||||
|
||||
Settings offers `gaugelabreference` and `maintenancereference` on machines, PCs,
|
||||
printers, network devices and measuring tools. Both were shown on the form and
|
||||
printers and network devices. Both were shown on the form and
|
||||
the detail page and neither was matched by any list's `?search=`, so someone
|
||||
holding a physical tag could read it, type it into the list they were already
|
||||
looking at, and get nothing - while global search found it.
|
||||
@@ -22,7 +22,6 @@ LISTS = [
|
||||
('/api/computers', '/api/computers', 'computer'),
|
||||
('/api/printers', '/api/printers', 'printer'),
|
||||
('/api/network', '/api/network', 'network_device'),
|
||||
('/api/measuringtools', '/api/measuringtools', 'measuring_tool'),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Locations carry no dedicated code column, so the operation code is the leading
|
||||
whitespace-delimited token of the location name (e.g. '0615 Blisk Inspection'
|
||||
-> '0615'). Printed asset labels for measuring tools encode this so every tool
|
||||
-> '0615'). A printed asset label can encode this so the thing
|
||||
at one operation shares one code.
|
||||
"""
|
||||
|
||||
|
||||
156
tests/test_core/test_migration_nest_app_packages.py
Normal file
156
tests/test_core/test_migration_nest_app_packages.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for the migration that nests uploaded installers by application id.
|
||||
|
||||
The migration rewrites installpath for every affected row and moves the file on
|
||||
disk. Both halves matter: a row left on the old scheme is a download that 404s
|
||||
against a route that no longer serves it, and a file left behind is a download
|
||||
that 404s against a row pointing at nothing.
|
||||
|
||||
These call upgrade()/downgrade() directly with a stubbed bind rather than
|
||||
running Alembic, so the file movement can be checked against a real temporary
|
||||
directory without a database round trip.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _load_migration():
|
||||
path = os.path.join('migrations', 'versions', '7d46_nest_app_packages.py')
|
||||
spec = importlib.util.spec_from_file_location('nest_app_packages', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeBind:
|
||||
"""Records UPDATEs and replays a fixed SELECT, so no DB is needed."""
|
||||
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
self.updates = []
|
||||
|
||||
def execute(self, statement, params=None):
|
||||
text = str(statement)
|
||||
if text.strip().startswith('SELECT'):
|
||||
return _FakeResult(self._rows)
|
||||
self.updates.append(params)
|
||||
return _FakeResult([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migration():
|
||||
return _load_migration()
|
||||
|
||||
|
||||
def _patch(monkeypatch, migration, bind, directory):
|
||||
monkeypatch.setattr(migration.op, 'get_bind', lambda: bind)
|
||||
monkeypatch.setattr(migration, '_package_dir', lambda: directory)
|
||||
|
||||
|
||||
def test_upgrade_moves_the_file_and_repoints_the_row(monkeypatch, migration,
|
||||
tmp_path):
|
||||
directory = str(tmp_path)
|
||||
flat = os.path.join(directory, 'application-7.msi')
|
||||
with open(flat, 'wb') as handle:
|
||||
handle.write(b'installer bytes')
|
||||
|
||||
bind = _FakeBind([(7, '/api/applications/package/application-7.msi')])
|
||||
_patch(monkeypatch, migration, bind, directory)
|
||||
|
||||
migration.upgrade()
|
||||
|
||||
assert not os.path.exists(flat)
|
||||
moved = os.path.join(directory, '7', 'installer.msi')
|
||||
assert os.path.isfile(moved)
|
||||
with open(moved, 'rb') as handle:
|
||||
assert handle.read() == b'installer bytes'
|
||||
|
||||
assert bind.updates == [
|
||||
{'path': '/api/applications/package/7/installer.msi', 'appid': 7}]
|
||||
|
||||
|
||||
def test_upgrade_repoints_the_row_even_when_the_file_is_missing(
|
||||
monkeypatch, migration, tmp_path):
|
||||
# A row left on the old scheme is unserviceable and cannot be repaired by
|
||||
# re-uploading, so the rewrite must not be conditional on the file.
|
||||
bind = _FakeBind([(9, '/api/applications/package/application-9.exe')])
|
||||
_patch(monkeypatch, migration, bind, str(tmp_path))
|
||||
|
||||
migration.upgrade()
|
||||
|
||||
assert bind.updates == [
|
||||
{'path': '/api/applications/package/9/installer.exe', 'appid': 9}]
|
||||
|
||||
|
||||
def test_upgrade_leaves_already_nested_rows_alone(monkeypatch, migration,
|
||||
tmp_path):
|
||||
# Re-running the migration must not rewrite a path that is already correct,
|
||||
# which would clobber a real vendor filename with "installer.exe".
|
||||
bind = _FakeBind([(3, '/api/applications/package/3/DeviceLoggerSetup.exe')])
|
||||
_patch(monkeypatch, migration, bind, str(tmp_path))
|
||||
|
||||
migration.upgrade()
|
||||
|
||||
assert bind.updates == []
|
||||
|
||||
|
||||
def test_upgrade_ignores_an_operator_typed_share_path(monkeypatch, migration,
|
||||
tmp_path):
|
||||
# The SELECT only matches our own prefix, but prove a UNC path is untouched.
|
||||
bind = _FakeBind([])
|
||||
_patch(monkeypatch, migration, bind, str(tmp_path))
|
||||
|
||||
migration.upgrade()
|
||||
|
||||
assert bind.updates == []
|
||||
|
||||
|
||||
def test_downgrade_puts_the_file_back(monkeypatch, migration, tmp_path):
|
||||
directory = str(tmp_path)
|
||||
nested = os.path.join(directory, '7')
|
||||
os.makedirs(nested)
|
||||
with open(os.path.join(nested, 'installer.msi'), 'wb') as handle:
|
||||
handle.write(b'installer bytes')
|
||||
|
||||
bind = _FakeBind([(7, '/api/applications/package/7/installer.msi')])
|
||||
_patch(monkeypatch, migration, bind, directory)
|
||||
|
||||
migration.downgrade()
|
||||
|
||||
flat = os.path.join(directory, 'application-7.msi')
|
||||
assert os.path.isfile(flat)
|
||||
# The emptied directory is cleaned up so a re-upgrade does not find it.
|
||||
assert not os.path.isdir(nested)
|
||||
assert bind.updates == [
|
||||
{'path': '/api/applications/package/application-7.msi', 'appid': 7}]
|
||||
|
||||
|
||||
def test_upgrade_then_downgrade_round_trips(monkeypatch, migration, tmp_path):
|
||||
directory = str(tmp_path)
|
||||
with open(os.path.join(directory, 'application-4.exe'), 'wb') as handle:
|
||||
handle.write(b'payload')
|
||||
|
||||
up = _FakeBind([(4, '/api/applications/package/application-4.exe')])
|
||||
_patch(monkeypatch, migration, up, directory)
|
||||
migration.upgrade()
|
||||
|
||||
down = _FakeBind([(4, up.updates[0]['path'])])
|
||||
_patch(monkeypatch, migration, down, directory)
|
||||
migration.downgrade()
|
||||
|
||||
restored = os.path.join(directory, 'application-4.exe')
|
||||
assert os.path.isfile(restored)
|
||||
with open(restored, 'rb') as handle:
|
||||
assert handle.read() == b'payload'
|
||||
assert down.updates[0]['path'] == \
|
||||
'/api/applications/package/application-4.exe'
|
||||
@@ -14,7 +14,7 @@ from shopdb.core.models.apitoken import ApiToken
|
||||
|
||||
# Permission-owning plugin domains that must NOT live in the core catalog.
|
||||
PLUGIN_PREFIXES = ('machines.', 'computers.', 'printers.', 'network.', 'kb.',
|
||||
'notifications.', 'usb.', 'warranty.', 'measuringtools.')
|
||||
'notifications.', 'warranty.')
|
||||
|
||||
|
||||
def test_core_catalog_excludes_plugin_permissions():
|
||||
|
||||
160
tests/test_core/test_plugin_settings_hubs.py
Normal file
160
tests/test_core/test_plugin_settings_hubs.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""A plugin settings tab is only real if a hub is written to draw it.
|
||||
|
||||
`get_settings_routes()` (ADR-019) lets a plugin declare a settings tab and name
|
||||
the hub it belongs to. The server accepts any hub string, but only a hub whose
|
||||
.vue page asks /api/plugins/settings-routes and calls router.addRoute() ever
|
||||
draws one. Today that is Model Catalog and nothing else.
|
||||
|
||||
The failure that gets is the quiet kind: the plugin is right, the API is right,
|
||||
the response carries the tab, and the person never sees it. So the supported set
|
||||
is named once in shopdb/plugins/base.py, the API drops anything outside it with
|
||||
an error in the log, and these tests hold the two ends together - no bundled
|
||||
plugin may declare a hub that cannot draw it, and the list in the server may not
|
||||
drift away from the hubs that actually register routes in the frontend.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.plugins import plugin_manager
|
||||
from shopdb.plugins.base import SETTINGS_HUBS_WITH_PLUGIN_TABS
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
HUB_SOURCES = sorted((REPO / 'frontend' / 'src' / 'views' / 'settings')
|
||||
.glob('*.vue'))
|
||||
|
||||
# router.addRoute('model-catalog', { ... }) - the parent whose children the hub
|
||||
# adds to, which is also the id a route's `hub` field has to carry.
|
||||
ADDROUTE = re.compile(r"router\.addRoute\(\s*['\"]([^'\"]+)['\"]")
|
||||
# ...filter(r => r.hub === 'model-catalog')
|
||||
HUB_FILTER = re.compile(r"\.hub\s*===\s*['\"]([^'\"]+)['\"]")
|
||||
|
||||
BUNDLED = sorted(path.parent.name
|
||||
for path in (REPO / 'plugins').glob('*/manifest.json'))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def declared_routes(app):
|
||||
"""Every bundled plugin's declared settings routes, enabled or not.
|
||||
|
||||
Read from the classes rather than the loaded plugins: a plugin disabled on
|
||||
this machine still ships, and a dead tab in it is still a defect.
|
||||
"""
|
||||
routes = {}
|
||||
with app.app_context():
|
||||
for name in BUNDLED:
|
||||
cls = plugin_manager.loader.load_plugin_class(name)
|
||||
assert cls is not None, f'Plugin class for {name} could not be loaded'
|
||||
routes[name] = cls().get_settings_routes() or []
|
||||
return routes
|
||||
|
||||
|
||||
def test_the_sweep_actually_reads_something():
|
||||
"""A gate that matches nothing passes forever."""
|
||||
assert len(HUB_SOURCES) > 5, [path.name for path in HUB_SOURCES]
|
||||
assert BUNDLED, 'no bundled plugins found - the glob is probably wrong'
|
||||
assert any(ADDROUTE.search(path.read_text()) for path in HUB_SOURCES), \
|
||||
'no hub registers plugin routes at all - the pattern is probably wrong'
|
||||
|
||||
|
||||
def test_every_bundled_plugin_declares_a_hub_that_can_draw_it(declared_routes):
|
||||
offenders = [
|
||||
"%s: route '%s' names hub '%s'" % (name, route.get('name'),
|
||||
route.get('hub'))
|
||||
for name, routes in declared_routes.items()
|
||||
for route in routes
|
||||
if route.get('hub') not in SETTINGS_HUBS_WITH_PLUGIN_TABS
|
||||
]
|
||||
assert not offenders, (
|
||||
'These settings tabs would be returned by the API and drawn by no one. '
|
||||
'Move them to a hub that supports plugin tabs (%s), or write the '
|
||||
'fetch-and-addRoute code into the hub page and add it to '
|
||||
'SETTINGS_HUBS_WITH_PLUGIN_TABS:\n %s'
|
||||
% (', '.join(SETTINGS_HUBS_WITH_PLUGIN_TABS), '\n '.join(offenders)))
|
||||
|
||||
|
||||
def test_the_supported_hubs_are_the_ones_the_frontend_registers():
|
||||
"""The server's list and the frontend's code cannot be allowed to drift.
|
||||
|
||||
A hub page supports plugin tabs when it does both halves: keeps the routes
|
||||
whose `hub` is its own id, and registers them with addRoute. A page doing
|
||||
only one of the two is worse than neither, so it counts as neither.
|
||||
"""
|
||||
registering = set()
|
||||
for path in HUB_SOURCES:
|
||||
source = path.read_text()
|
||||
if not ADDROUTE.search(source):
|
||||
continue
|
||||
registering.update(HUB_FILTER.findall(source))
|
||||
|
||||
assert registering == set(SETTINGS_HUBS_WITH_PLUGIN_TABS), (
|
||||
'SETTINGS_HUBS_WITH_PLUGIN_TABS says %s; the settings hubs that fetch '
|
||||
'and register plugin routes are %s. Whichever one moved, the other has '
|
||||
'to move with it.'
|
||||
% (sorted(SETTINGS_HUBS_WITH_PLUGIN_TABS), sorted(registering)))
|
||||
|
||||
|
||||
def test_a_declared_tab_is_also_reachable_by_a_static_route(declared_routes):
|
||||
"""A settings tab cannot be the only way to a plugin's page.
|
||||
|
||||
The hub loads the tab with `import(`../../${tab.component}`)`, a template
|
||||
with no static file extension. Vite's dynamic-import-vars plugin refuses to
|
||||
rewrite that ("A file extension must be included in the static part of the
|
||||
import"), so a production build emits the expression verbatim and the
|
||||
browser asks for a .vue path that no build ships. The tab therefore works
|
||||
under `npm run dev`, where the dev server serves sources by path, and not in
|
||||
a built bundle.
|
||||
|
||||
Until that is decided one way or the other (ADR-019), a plugin that declares
|
||||
a tab must also register the same page in its own routes.js, the way
|
||||
computers does with /settings/operatingsystems - a static import, a real
|
||||
URL, and the thing production actually uses.
|
||||
"""
|
||||
routesjs = re.compile(r"import\(\s*['\"]([^'\"]+\.vue)['\"]")
|
||||
offenders = []
|
||||
for name, routes in declared_routes.items():
|
||||
source = REPO / 'plugins' / name / 'frontend' / 'routes.js'
|
||||
statics = {Path(match).name
|
||||
for match in routesjs.findall(source.read_text())} \
|
||||
if source.exists() else set()
|
||||
for route in routes:
|
||||
component = Path(route.get('component') or '').name
|
||||
if component not in statics:
|
||||
offenders.append("%s: '%s' (%s) has no static route"
|
||||
% (name, route.get('name'), component))
|
||||
|
||||
assert not offenders, (
|
||||
'These settings pages are reachable only through a hub tab, whose '
|
||||
'component import does not survive a production build. Give each one a '
|
||||
'route in the plugin routes.js as well:\n ' + '\n '.join(offenders))
|
||||
|
||||
|
||||
def test_a_route_for_an_unsupported_hub_is_dropped_and_logged(
|
||||
app, client, db, monkeypatch, caplog, auth_headers):
|
||||
manager = app.extensions.get('plugin_manager')
|
||||
plugins = manager.get_all_plugins()
|
||||
enabled = [name for name in plugins if manager.registry.is_enabled(name)]
|
||||
assert enabled
|
||||
|
||||
def contribute():
|
||||
return [
|
||||
{'hub': 'model-catalog', 'path': 'drawn', 'name': 'drawn-route',
|
||||
'tab': 'Drawn', 'component': 'drawn.vue'},
|
||||
{'hub': 'asset-config', 'path': 'dead', 'name': 'dead-route',
|
||||
'tab': 'Dead', 'component': 'dead.vue'},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(plugins[enabled[0]], 'get_settings_routes', contribute,
|
||||
raising=False)
|
||||
|
||||
with caplog.at_level('ERROR'):
|
||||
response = client.get('/api/plugins/settings-routes',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
names = [route['name'] for route in response.get_json()['data']['routes']]
|
||||
assert 'drawn-route' in names
|
||||
assert 'dead-route' not in names
|
||||
assert any('asset-config' in record.getMessage() for record in caplog.records), \
|
||||
'the drop has to be loud, or it is the same silence in a new place'
|
||||
47
tests/test_core/test_plugin_settings_routes.py
Normal file
47
tests/test_core/test_plugin_settings_routes.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""GET /api/plugins/settings-routes: isolation, and who may read it."""
|
||||
|
||||
|
||||
def test_a_raising_plugin_is_skipped_not_fatal(app, client, db, monkeypatch,
|
||||
auth_headers):
|
||||
manager = app.extensions.get('plugin_manager')
|
||||
plugins = manager.get_all_plugins()
|
||||
enabled = [name for name in plugins if manager.registry.is_enabled(name)]
|
||||
assert len(enabled) >= 2
|
||||
|
||||
broken, healthy = enabled[0], enabled[1]
|
||||
|
||||
def explode():
|
||||
raise RuntimeError('broken settings hook')
|
||||
|
||||
def contribute():
|
||||
return [{'hub': 'model-catalog', 'path': 'probe', 'name': 'probe-route',
|
||||
'tab': 'Probe', 'component': 'probe.vue'}]
|
||||
|
||||
monkeypatch.setattr(plugins[broken], 'get_settings_routes', explode,
|
||||
raising=False)
|
||||
monkeypatch.setattr(plugins[healthy], 'get_settings_routes', contribute,
|
||||
raising=False)
|
||||
|
||||
response = client.get('/api/plugins/settings-routes',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
names = [route['name'] for route in response.get_json()['data']['routes']]
|
||||
assert 'probe-route' in names
|
||||
|
||||
|
||||
def test_an_anonymous_caller_is_refused(client, db):
|
||||
"""It names the enabled plugins, the settings hub layout and the internal
|
||||
component paths. That is reconnaissance, and nothing needs it before login -
|
||||
its only caller is the Model Catalog hub, behind auth and admin."""
|
||||
response = client.get('/api/plugins/settings-routes')
|
||||
|
||||
assert response.status_code in (401, 403, 422), response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_a_caller_without_settings_view_is_refused(client, db, member_headers):
|
||||
"""Signed in is not enough: the gate is the permission, not the session."""
|
||||
response = client.get('/api/plugins/settings-routes',
|
||||
headers=member_headers)
|
||||
|
||||
assert response.status_code == 403, response.get_data(as_text=True)
|
||||
@@ -4,7 +4,14 @@ Pins the wiring added for the four data-only presentation hooks
|
||||
(get_settings_cards, get_asset_panels, get_map_overlays,
|
||||
get_asset_presentation): each endpoint aggregates enabled plugins'
|
||||
contributions, tags them with the plugin name, and drops disabled plugins.
|
||||
Also asserts the pilot plugins (warranty, measuringtools) contribute.
|
||||
Two of the four hooks have a real implementer in this build and are asserted
|
||||
against it: printers contributes a settings card, warranty an asset panel.
|
||||
get_map_overlays and get_asset_presentation have NO bundled implementer, so
|
||||
those two are driven against a monkeypatched stub plugin. That is deliberate:
|
||||
the hooks are part of the plugin contract a site writes against, and the thing
|
||||
under test is core's aggregation - tagging, sorting, and dropping a disabled
|
||||
plugin - not which plugin happens to declare one this month. Deleting those
|
||||
tests would leave two shipped hooks and two live routes with no coverage at all.
|
||||
|
||||
Plugin enabled-state is monkeypatched (not persisted) so these tests do not
|
||||
mutate the shared instance/plugins.json registry file.
|
||||
@@ -24,27 +31,27 @@ def _data(client, path, headers):
|
||||
# =============================================================================
|
||||
|
||||
def test_settings_cards_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools contributes its settings card, tagged + position-sorted."""
|
||||
"""printers contributes its settings card, tagged + position-sorted."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
|
||||
cards = _data(client, '/api/pluginui/settings-cards', auth_headers)
|
||||
plugins = {c.get('plugin') for c in cards}
|
||||
assert 'measuringtools' in plugins
|
||||
card = next(c for c in cards if c['plugin'] == 'measuringtools')
|
||||
assert card['group'] == 'Measuring Tools'
|
||||
assert card['to'] == '/settings/measuringtooltypes'
|
||||
assert 'printers' in plugins
|
||||
card = next(c for c in cards if c['plugin'] == 'printers')
|
||||
assert card['group'] == 'Printers'
|
||||
assert card['to'] == '/settings/printer-alerts'
|
||||
positions = [c.get('position', 99) for c in cards]
|
||||
assert positions == sorted(positions)
|
||||
|
||||
|
||||
def test_settings_cards_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
"""A disabled measuringtools drops its settings card."""
|
||||
"""A disabled printers drops its settings card."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'printers')
|
||||
|
||||
cards = _data(client, '/api/pluginui/settings-cards', auth_headers)
|
||||
assert 'measuringtools' not in {c.get('plugin') for c in cards}
|
||||
assert 'printers' not in {c.get('plugin') for c in cards}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -100,48 +107,77 @@ def test_asset_panels_skip_disabled_plugin(app, client, db, auth_headers, monkey
|
||||
# map-overlays
|
||||
# =============================================================================
|
||||
|
||||
# No bundled plugin implements get_map_overlays, so a stub declares one. See the
|
||||
# module docstring: core's aggregation is what is under test.
|
||||
OVERLAY = {'id': 'due-soon', 'title': 'Due soon', 'endpoint': '/api/whatever',
|
||||
'legend': True}
|
||||
|
||||
|
||||
def _stub_overlay_plugin(pm, monkeypatch):
|
||||
"""Give one loaded plugin an overlay and return its name."""
|
||||
name, plugin = next(iter(pm.get_all_plugins().items()))
|
||||
monkeypatch.setattr(plugin, 'get_map_overlays', lambda: [dict(OVERLAY)])
|
||||
return name
|
||||
|
||||
|
||||
def test_map_overlays_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools contributes its calibration-due overlay."""
|
||||
"""A declared overlay comes back tagged with the plugin that declared it."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
name = _stub_overlay_plugin(pm, monkeypatch)
|
||||
|
||||
overlays = _data(client, '/api/pluginui/map-overlays', auth_headers)
|
||||
overlay = next((o for o in overlays if o.get('plugin') == 'measuringtools'), None)
|
||||
overlay = next((o for o in overlays if o.get('plugin') == name), None)
|
||||
assert overlay is not None
|
||||
assert overlay['id'] == 'calibration-due'
|
||||
assert overlay['id'] == 'due-soon'
|
||||
assert overlay['legend'] is True
|
||||
|
||||
|
||||
def test_map_overlays_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
name = _stub_overlay_plugin(pm, monkeypatch)
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda n: n != name)
|
||||
|
||||
overlays = _data(client, '/api/pluginui/map-overlays', auth_headers)
|
||||
assert 'measuringtools' not in {o.get('plugin') for o in overlays}
|
||||
assert name not in {o.get('plugin') for o in overlays}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# asset-presentation
|
||||
# =============================================================================
|
||||
|
||||
# No bundled plugin implements get_asset_presentation either; same stub rule.
|
||||
PRESENTATION = {'assettype': 'stubtype',
|
||||
'route': '/stubtype/by-asset/{assetid}'}
|
||||
|
||||
|
||||
def _stub_presentation_plugin(pm, monkeypatch):
|
||||
name, plugin = next(iter(pm.get_all_plugins().items()))
|
||||
monkeypatch.setattr(plugin, 'get_asset_presentation',
|
||||
lambda: [dict(PRESENTATION)])
|
||||
return name
|
||||
|
||||
|
||||
def test_asset_presentation_aggregate_enabled_plugins(app, client, auth_headers, monkeypatch):
|
||||
"""measuringtools declares presentation for its measuring_tool type."""
|
||||
"""A declared presentation route comes back tagged with its plugin."""
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: True)
|
||||
name = _stub_presentation_plugin(pm, monkeypatch)
|
||||
|
||||
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
|
||||
entry = next((e for e in entries if e.get('assettype') == 'measuring_tool'), None)
|
||||
entry = next((e for e in entries if e.get('assettype') == 'stubtype'), None)
|
||||
assert entry is not None
|
||||
assert entry['plugin'] == 'measuringtools'
|
||||
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
|
||||
assert entry['plugin'] == name
|
||||
assert entry['route'] == '/stubtype/by-asset/{assetid}'
|
||||
|
||||
|
||||
def test_asset_presentation_skip_disabled_plugin(app, client, auth_headers, monkeypatch):
|
||||
pm = app.extensions['plugin_manager']
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda name: name != 'measuringtools')
|
||||
name = _stub_presentation_plugin(pm, monkeypatch)
|
||||
monkeypatch.setattr(pm.registry, 'is_enabled', lambda n: n != name)
|
||||
|
||||
entries = _data(client, '/api/pluginui/asset-presentation', auth_headers)
|
||||
assert 'measuring_tool' not in {e.get('assettype') for e in entries}
|
||||
assert 'stubtype' not in {e.get('assettype') for e in entries}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
44
tests/test_core/test_pluginui_panel_groups.py
Normal file
44
tests/test_core/test_pluginui_panel_groups.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""The server-side panel group vocabulary must admit every group a plugin uses.
|
||||
|
||||
`asset_panels()` resolves a panel's group BEFORE the client sees it, coercing
|
||||
any unrecognised value to 'details'. That makes ASSET_PANEL_GROUPS a third copy
|
||||
of the tab vocabulary, alongside RENDERED_GROUPS in PluginAssetPanels.vue and
|
||||
the tab list in PCDetail.vue. Phase 3 added 'serviceprocess' to the two
|
||||
frontend copies and missed this one, so both Service/Process panels were
|
||||
silently relabelled 'details' on the wire and the tab never appeared.
|
||||
|
||||
The failure is invisible: no error, no log line, and the panels still render -
|
||||
just in the wrong tab, underneath the Details cards.
|
||||
"""
|
||||
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
|
||||
def test_every_group_the_endpoints_plugin_declares_survives_resolution():
|
||||
declared = {
|
||||
str(panel.get('group') or '').strip().lower()
|
||||
for panel in EndpointsPlugin().get_asset_panels()
|
||||
}
|
||||
declared.discard('')
|
||||
unknown = declared - set(ASSET_PANEL_GROUPS)
|
||||
assert not unknown, (
|
||||
f'panels declare {sorted(unknown)}, which the server rewrites to '
|
||||
"'details' - the tab will not appear"
|
||||
)
|
||||
|
||||
|
||||
def test_serviceprocess_is_admitted():
|
||||
# Named explicitly: the generic test above would also pass if the
|
||||
# serviceprocess panels were deleted outright.
|
||||
assert 'serviceprocess' in ASSET_PANEL_GROUPS
|
||||
|
||||
|
||||
def test_reports_is_admitted():
|
||||
# Same reasoning as serviceprocess above. Operator Reports was moved out of
|
||||
# System Monitors into a tab of its own, which is the same three-copy
|
||||
# change that Phase 3 got wrong.
|
||||
assert 'reports' in ASSET_PANEL_GROUPS
|
||||
|
||||
|
||||
def test_details_remains_the_fallback():
|
||||
assert ASSET_PANEL_GROUPS[0] == 'details'
|
||||
@@ -26,6 +26,12 @@ PUBLIC_ENDPOINTS = {
|
||||
'setup.needs_admin',
|
||||
'auth.get_current_user',
|
||||
'plugins.list_enabled_plugins',
|
||||
# plugins.get_plugin_settings_routes was here, on the reasoning that
|
||||
# unauthenticated pages had to register plugin tabs before login. Nothing
|
||||
# did: its only caller is the Model Catalog settings hub, which is behind
|
||||
# requiresAuth + requiresAdmin. Open, it named the enabled plugins, the
|
||||
# settings hub layout and the internal component paths to anybody who
|
||||
# asked, so it is now gated on settings.view like the plugin list beside it.
|
||||
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
||||
# what the guest role can reach
|
||||
'dashboard.get_capabilities', # booleans about the CALLER, used to decide
|
||||
@@ -43,7 +49,6 @@ PUBLIC_ENDPOINTS = {
|
||||
# branding logo renders on the LOGIN page.
|
||||
'settings.serve_branding_logo',
|
||||
'settings.serve_map_blueprint',
|
||||
'employees.serve_employee_photo',
|
||||
'applications.serve_application_image',
|
||||
# Same reasoning: a machine detail page renders the catalog model's photo
|
||||
# with <img :src="withBase(imageurl)">, which sends no Authorization
|
||||
@@ -72,14 +77,6 @@ PUBLIC_ENDPOINTS = {
|
||||
'maplevels.list_levels', 'maplevels.get_level',
|
||||
'maplevels.serve_blueprint',
|
||||
|
||||
# Parts kiosk: one item by its code, and that item's picture.
|
||||
# kiosk_item takes no JWT but is NOT open: same kiosk flag + device token
|
||||
# as the write. Gating only the write would have left the enumeration
|
||||
# oracle running against a kiosk a site had switched off.
|
||||
'printedparts.kiosk_item', 'printedparts.serve_item_image',
|
||||
|
||||
# USB kiosk.
|
||||
'usb.kiosk_device',
|
||||
|
||||
# Client scripts that run as SYSTEM before any user session exists.
|
||||
'printers_asset.printers_for_host',
|
||||
@@ -87,9 +84,6 @@ PUBLIC_ENDPOINTS = {
|
||||
'printers_asset.printer_install_batch',
|
||||
'printers_asset.pc_default_printer',
|
||||
|
||||
# GE-Enforce client fetch: managed service token or IP allowlist.
|
||||
'geenforce.get_manifest', 'geenforce.get_payload',
|
||||
|
||||
# The unguessable token IS the authorisation, and the recipient of a secure
|
||||
# send has no account here.
|
||||
'tools.peek_secret',
|
||||
@@ -120,6 +114,28 @@ PUBLIC_ENDPOINTS = {
|
||||
# It is a read of already-scoped policy output, not an authority: attaching a
|
||||
# monitor is gated on the policy editor, audited with the requesting user.
|
||||
'endpoints.resolved_monitors_for_agent',
|
||||
|
||||
# The endpoint agent fetches the active, unacknowledged fleet alerts for its
|
||||
# own hostname so the tray can surface them to the operator. Authenticated
|
||||
# inside the handler by the endpoints collector API key - the same shape as
|
||||
# claim_next_command - because a bay has no user session. The response is
|
||||
# scoped to the asset the reported hostname resolves to, so this reads one
|
||||
# machine's alerts rather than the fleet's. Authoring a fleet alert is the
|
||||
# actual authority and stays gated in the notifications plugin.
|
||||
'endpoints.get_fleet_alerts',
|
||||
|
||||
# The one-line bootstrap installer. A tech pastes one command into an
|
||||
# elevated PowerShell on a PC that has no agent and therefore no credential
|
||||
# and no user session, so neither route can carry a token in a header: the
|
||||
# whole authorization is the short-lived bootstrap token in ?t=, minted by
|
||||
# an admin (role-gated, audited) and revocable. Both 404 on anything not
|
||||
# currently usable so the URL confirms nothing, both are per-IP rate
|
||||
# limited, and both are audited with the caller's IP. What they hand out is
|
||||
# deliberately not an authority: the package NEVER carries the fleet key,
|
||||
# so the PC it installs can only sit in Pending until an admin approves its
|
||||
# enrollment.
|
||||
'endpoints.serve_bootstrap_script',
|
||||
'endpoints.download_bootstrap_package',
|
||||
}
|
||||
|
||||
# Reads that need a LOGIN but no named permission, because the handler decides
|
||||
|
||||
129
tests/test_core/test_script_captureoutput.py
Normal file
129
tests/test_core/test_script_captureoutput.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""The per-script "return output on success" flag.
|
||||
|
||||
A failed run has always sent its output back, because a failure has to be
|
||||
diagnosable from the UI. A successful run has not, and that is the behaviour
|
||||
being made opt-in here rather than simply reversed: script output contains
|
||||
whatever the body printed, and persisting that server-side for every routine
|
||||
green run is exposure nobody asked for.
|
||||
|
||||
The load-bearing property is the default. Every script authored before this
|
||||
field existed ran under "success stays silent", so an absent field must never
|
||||
be read as consent.
|
||||
"""
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(client, db):
|
||||
from shopdb.core.models import User
|
||||
|
||||
Permission.seed_entries([SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
user = User(
|
||||
username='captureauthor',
|
||||
email='captureauthor@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
user.directpermissions.append(
|
||||
Permission.query.filter_by(name='endpoints.runscript').one())
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': 'captureauthor', 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
|
||||
|
||||
|
||||
def _create(client, headers, name, **extra):
|
||||
payload = {'scriptname': name}
|
||||
payload.update(extra)
|
||||
return client.post('/api/scripts', headers=headers, json=payload)
|
||||
|
||||
|
||||
def test_a_script_without_the_flag_withholds_output(client, headers):
|
||||
"""The load-bearing test. An omitted field is not consent to persist a
|
||||
successful run's output server-side."""
|
||||
created = _create(client, headers, 'no-capture')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['captureoutput'] is False
|
||||
|
||||
|
||||
def test_the_flag_can_be_turned_on(client, headers):
|
||||
created = _create(client, headers, 'chatty-one', captureoutput=True)
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['captureoutput'] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sent,expected', [
|
||||
('true', True),
|
||||
('false', False),
|
||||
(True, True),
|
||||
(False, False),
|
||||
])
|
||||
def test_form_style_values_are_accepted(client, headers, sent, expected):
|
||||
"""The field arrives from a checkbox, which may send a string."""
|
||||
created = _create(client, headers, f'form-{sent}-{expected}',
|
||||
captureoutput=sent)
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['captureoutput'] is expected
|
||||
|
||||
|
||||
def test_a_nonsense_value_is_rejected(client, headers):
|
||||
"""Rejected rather than coerced: silently reading garbage as False would
|
||||
hide a broken client, and as True would leak output nobody agreed to."""
|
||||
created = _create(client, headers, 'bad-capture', captureoutput='maybe')
|
||||
|
||||
assert created.status_code == 400
|
||||
assert 'captureoutput' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_the_flag_can_be_changed_on_an_existing_script(client, headers):
|
||||
scriptid = _create(client, headers, 'toggle-me',
|
||||
captureoutput=True).get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'captureoutput': False})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['captureoutput'] is False
|
||||
|
||||
|
||||
def test_an_unrelated_update_leaves_the_flag_alone(client, headers):
|
||||
"""Editing a description must not quietly revert an author's decision."""
|
||||
scriptid = _create(client, headers, 'keep-my-flag',
|
||||
captureoutput=True).get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'scriptdescription': 'now documented'})
|
||||
|
||||
assert updated.get_json()['data']['captureoutput'] is True
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_carries_the_flag(app, client, db, headers):
|
||||
"""The agent cannot honour a decision it is never told about."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
|
||||
with app.app_context():
|
||||
from shopdb.core.models import Script
|
||||
|
||||
script = Script(scriptname='delivered', filename='script-1.ps1',
|
||||
sha256='a' * 64, runcontext='system',
|
||||
captureoutput=True)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, err = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert err is None
|
||||
assert descriptor['captureoutput'] is True
|
||||
138
tests/test_core/test_script_errormode.py
Normal file
138
tests/test_core/test_script_errormode.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""The per-script error mode.
|
||||
|
||||
PowerShell treats most errors as non-terminating: it writes the error, runs the
|
||||
next line, and still exits zero. The agent judges a run by that exit code, so a
|
||||
script could report success having accomplished none of its work - which is
|
||||
exactly what happened with the account-provisioning script that "succeeded"
|
||||
without provisioning anything.
|
||||
|
||||
Strict mode fixes the verdict by making the first error terminating. It is
|
||||
deliberately NOT the default, because it changes how an existing body behaves
|
||||
partway through: a script that tolerates a failing cleanup, probes with a cmdlet
|
||||
that errors when something is absent, or loops past bad items would start
|
||||
aborting midway and leave the endpoint half-configured. These tests pin the
|
||||
permissive default hard, since that is the property protecting every script
|
||||
authored before this field existed.
|
||||
"""
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(client, db):
|
||||
from shopdb.core.models import User
|
||||
|
||||
Permission.seed_entries([SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
user = User(
|
||||
username='modeauthor',
|
||||
email='modeauthor@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
user.directpermissions.append(
|
||||
Permission.query.filter_by(name='endpoints.runscript').one())
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': 'modeauthor', 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
|
||||
|
||||
|
||||
def _create(client, headers, name, **extra):
|
||||
payload = {'scriptname': name}
|
||||
payload.update(extra)
|
||||
return client.post('/api/scripts', headers=headers, json=payload)
|
||||
|
||||
|
||||
def test_a_script_without_a_mode_stays_permissive(client, headers):
|
||||
"""The load-bearing test. Every script written before this field existed
|
||||
was authored against permissive semantics; defaulting to strict would
|
||||
change how all of them run without anyone touching them."""
|
||||
created = _create(client, headers, 'no-mode')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'default'
|
||||
|
||||
|
||||
def test_strict_is_stored_and_returned(client, headers):
|
||||
created = _create(client, headers, 'strict-one', errormode='strict')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'strict'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', ['stop', 'true', 'yes', 'none', ''])
|
||||
def test_an_unrecognised_mode_is_rejected(client, headers, bad):
|
||||
"""Rejected rather than coerced. Silently falling back to permissive would
|
||||
let a caller believe a script fails loudly when it does not, which is the
|
||||
precise failure this field exists to remove."""
|
||||
created = _create(client, headers, f'bad-{bad or "blank"}', errormode=bad)
|
||||
|
||||
assert created.status_code == 400, created.get_json()
|
||||
assert 'errormode' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_casing_is_normalised_rather_than_refused(client, headers):
|
||||
"""A caller typing Strict means strict. Case is a keyboard artefact, not a
|
||||
different intention, so it is normalised - unlike the values above, which
|
||||
are genuinely different words and could mean something the author did not
|
||||
intend."""
|
||||
created = _create(client, headers, 'cased', errormode='Strict')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert created.get_json()['data']['errormode'] == 'strict'
|
||||
|
||||
|
||||
def test_a_script_can_be_moved_back_to_permissive(client, headers):
|
||||
"""Strict has to be reversible without recreating the script: the way you
|
||||
discover a body cannot tolerate it is by watching it abort in production."""
|
||||
scriptid = _create(client, headers, 'reversible',
|
||||
errormode='strict').get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'errormode': 'default'})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['errormode'] == 'default'
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_always_carries_the_field(client, db, headers):
|
||||
"""Always present, even at the default, so an agent that received it and
|
||||
ignored it is distinguishable from one that never saw it."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered-strict', filename='script-1.ps1',
|
||||
sha256='a' * 64, errormode='strict')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['errormode'] == 'strict'
|
||||
|
||||
|
||||
def test_the_descriptor_defaults_rather_than_omitting(client, db, headers):
|
||||
"""An absent key would leave the agent guessing. It sends the permissive
|
||||
value explicitly so both ends agree on what unset means."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered-default', filename='script-2.ps1',
|
||||
sha256='b' * 64)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['errormode'] == 'default'
|
||||
160
tests/test_core/test_script_library_gate.py
Normal file
160
tests/test_core/test_script_library_gate.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""The script library is gated on its own grant, not on applications.*.
|
||||
|
||||
A script here is not an application. An installer is a vendor artefact; a script
|
||||
is arbitrary PowerShell this site wrote that the agent runs as SYSTEM. While the
|
||||
library was gated on applications.*, anyone holding the software-delivery grant
|
||||
could rewrite the bytes of a script a trusted operator later pushed - the push
|
||||
was gated on endpoints.runscript, but the CONTENT of what got pushed was not.
|
||||
That is privilege escalation wearing a content-management hat.
|
||||
|
||||
These tests pin the gate itself. The library had NO coverage before this.
|
||||
"""
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
APPLICATIONS_GRANTS = [
|
||||
('applications.view', 'View applications', 'applications'),
|
||||
('applications.create', 'Create applications', 'applications'),
|
||||
('applications.edit', 'Edit applications', 'applications'),
|
||||
('applications.delete', 'Delete applications', 'applications'),
|
||||
('applications.download', 'Download installers', 'applications'),
|
||||
]
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded(db):
|
||||
Permission.seed_entries(APPLICATIONS_GRANTS + [SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _user_with(client, db, username, grants):
|
||||
"""Create a user holding exactly the named permissions, return its headers.
|
||||
|
||||
Direct permissions, no role: a role named 'admin' would short-circuit the
|
||||
gate and prove nothing about the permission under test.
|
||||
"""
|
||||
from shopdb.core.models import Permission, User
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=f'{username}@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
for name in grants:
|
||||
permission = Permission.query.filter_by(name=name).one()
|
||||
user.directpermissions.append(permission)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
token = login.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def test_the_applications_grant_does_not_open_the_script_library(
|
||||
client, db, seeded):
|
||||
"""The whole point: every applications.* grant, and still no access."""
|
||||
headers = _user_with(client, db, 'appsonly',
|
||||
[name for name, _d, _c in APPLICATIONS_GRANTS])
|
||||
|
||||
assert client.get('/api/scripts', headers=headers).status_code == 403
|
||||
assert client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'x'}).status_code == 403
|
||||
|
||||
|
||||
def test_the_runscript_grant_opens_the_script_library(client, db, seeded):
|
||||
headers = _user_with(client, db, 'pusher', ['endpoints.runscript'])
|
||||
|
||||
assert client.get('/api/scripts', headers=headers).status_code == 200
|
||||
created = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'map-drives'})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
|
||||
|
||||
def test_writing_a_script_body_needs_the_same_grant(client, db, seeded):
|
||||
"""Uploading the bytes is the dangerous half; it must not be cheaper to
|
||||
reach than the push that runs them."""
|
||||
author = _user_with(client, db, 'author', ['endpoints.runscript'])
|
||||
created = client.post('/api/scripts', headers=author,
|
||||
json={'scriptname': 'payload'})
|
||||
scriptid = created.get_json()['data']['scriptid']
|
||||
|
||||
appsuser = _user_with(client, db, 'appsuser2',
|
||||
[name for name, _d, _c in APPLICATIONS_GRANTS])
|
||||
body = (io.BytesIO(b"Write-Output 'hi'\r\n"), 'payload.ps1')
|
||||
denied = client.post(f'/api/scripts/{scriptid}/body', headers=appsuser,
|
||||
data={'file': body},
|
||||
content_type='multipart/form-data')
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_reading_a_script_body_needs_the_same_grant(client, db, seeded):
|
||||
"""Reading is not a lesser act: the body is the SYSTEM-context PowerShell
|
||||
itself, so it must not be readable by a weaker grant than writing it."""
|
||||
appsuser = _user_with(client, db, 'appsuser3',
|
||||
[name for name, _d, _c in APPLICATIONS_GRANTS])
|
||||
response = client.get('/api/scripts/body/script-1.ps1', headers=appsuser)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_upload_pins_the_digest_of_the_stored_bytes(client, db, seeded):
|
||||
"""The agent refuses a body whose sha256 does not match, so a wrong digest
|
||||
here is a script that can never run. Untested until now."""
|
||||
import hashlib
|
||||
|
||||
headers = _user_with(client, db, 'digest', ['endpoints.runscript'])
|
||||
created = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'digest-check'})
|
||||
scriptid = created.get_json()['data']['scriptid']
|
||||
|
||||
payload = b"Write-Output 'pinned'\r\n"
|
||||
uploaded = client.post(
|
||||
f'/api/scripts/{scriptid}/body', headers=headers,
|
||||
data={'file': (io.BytesIO(payload), 'digest-check.ps1')},
|
||||
content_type='multipart/form-data')
|
||||
assert uploaded.status_code == 200, uploaded.get_json()
|
||||
|
||||
data = uploaded.get_json()['data']
|
||||
assert data['sha256'] == hashlib.sha256(payload).hexdigest()
|
||||
assert data['hasbody'] is True
|
||||
|
||||
|
||||
def test_a_duplicate_script_name_is_refused(client, db, seeded):
|
||||
"""The accidental-overwrite guard the library was specified to have."""
|
||||
headers = _user_with(client, db, 'dupes', ['endpoints.runscript'])
|
||||
first = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'same-name'})
|
||||
assert first.status_code == 201
|
||||
second = client.post('/api/scripts', headers=headers,
|
||||
json={'scriptname': 'same-name'})
|
||||
assert second.status_code == 400
|
||||
assert 'already exists' in second.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_no_prebuilt_scope_confers_the_script_permission():
|
||||
"""Pins the decision NOT to backfill the tightened gate.
|
||||
|
||||
Moving the library onto endpoints.runscript takes it away from operator
|
||||
scope holders, who reach applications.create/edit. The tempting upgrade fix
|
||||
is to grant endpoints.runscript to whoever holds applications.edit - that
|
||||
would undo the whole change AND hand every operator remote SYSTEM code
|
||||
execution, because the same grant gates the push. If this test fails,
|
||||
someone has widened a scope; do not relax it.
|
||||
"""
|
||||
from shopdb.core.models.user import (
|
||||
SCOPE_NEVER_GRANT,
|
||||
scope_permission_names,
|
||||
)
|
||||
from shopdb.core.api.scripts import SCRIPT_PERMISSION
|
||||
|
||||
assert SCRIPT_PERMISSION in SCOPE_NEVER_GRANT
|
||||
for scope in ('reporter', 'technician', 'operator'):
|
||||
assert SCRIPT_PERMISSION not in scope_permission_names(scope)
|
||||
149
tests/test_core/test_script_workingdirectory.py
Normal file
149
tests/test_core/test_script_workingdirectory.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""The optional per-script working directory.
|
||||
|
||||
A delivered script runs from the agent's staging directory. That is correct for
|
||||
a self-contained script and wrong for one that shells out to a corporate tool
|
||||
already installed on the endpoint by Intune and invokes it by relative path -
|
||||
sfld_autologon needs Autologon.exe beside it. This field pins the folder.
|
||||
|
||||
The validation here is a SHAPE check, not a privilege boundary: the body is
|
||||
already arbitrary SYSTEM PowerShell that could Set-Location anywhere. These
|
||||
tests pin that distinction so the field is not later mistaken for a security
|
||||
control, and pin the default so old scripts keep behaving exactly as before.
|
||||
"""
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
|
||||
SCRIPT_GRANT = ('endpoints.runscript', 'Run staged scripts', 'endpoints')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def headers(client, db):
|
||||
from shopdb.core.models import User
|
||||
|
||||
Permission.seed_entries([SCRIPT_GRANT])
|
||||
db.session.commit()
|
||||
|
||||
user = User(
|
||||
username='scriptauthor',
|
||||
email='scriptauthor@test.local',
|
||||
passwordhash=generate_password_hash('testpass'),
|
||||
)
|
||||
user.directpermissions.append(
|
||||
Permission.query.filter_by(name='endpoints.runscript').one())
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': 'scriptauthor', 'password': 'testpass'})
|
||||
assert login.status_code == 200, login.get_json()
|
||||
return {'Authorization': f"Bearer {login.get_json()['data']['access_token']}"}
|
||||
|
||||
|
||||
def _create(client, headers, name, **extra):
|
||||
payload = {'scriptname': name}
|
||||
payload.update(extra)
|
||||
return client.post('/api/scripts', headers=headers, json=payload)
|
||||
|
||||
|
||||
def test_a_script_without_a_folder_stays_unpinned(client, headers):
|
||||
"""The default must remain "the agent decides", or every pre-existing
|
||||
script silently acquires a folder it was never authored for."""
|
||||
created = _create(client, headers, 'no-folder')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert not created.get_json()['data']['workingdirectory']
|
||||
|
||||
|
||||
def test_a_folder_is_stored_and_returned(client, headers):
|
||||
created = _create(client, headers, 'autologon',
|
||||
workingdirectory=r'C:\Program Files\Sysinternals')
|
||||
|
||||
assert created.status_code == 201, created.get_json()
|
||||
assert (created.get_json()['data']['workingdirectory']
|
||||
== r'C:\Program Files\Sysinternals')
|
||||
|
||||
|
||||
def test_a_trailing_separator_is_trimmed(client, headers):
|
||||
created = _create(client, headers, 'trailing',
|
||||
workingdirectory='C:\\Tools\\')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == r'C:\Tools'
|
||||
|
||||
|
||||
def test_a_drive_root_keeps_its_separator(client, headers):
|
||||
r"""C:\ must not degrade to C:. On Windows "C:" is drive-RELATIVE and
|
||||
resolves to wherever the process last was on that drive, which is the exact
|
||||
opposite of pinning a folder - a silent wrong answer, not an error."""
|
||||
created = _create(client, headers, 'driveroot', workingdirectory='C:\\')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == 'C:\\'
|
||||
|
||||
|
||||
def test_forward_slashes_are_normalized(client, headers):
|
||||
created = _create(client, headers, 'slashes',
|
||||
workingdirectory='C:/Tools/Corporate')
|
||||
|
||||
assert created.get_json()['data']['workingdirectory'] == r'C:\Tools\Corporate'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [
|
||||
r'Tools\Corporate', # relative: relative to WHAT?
|
||||
r'\\fileserver\tools', # UNC: machine-account access to a file server
|
||||
r'C:\Tools\..\..\Windows', # traversal: obscures the real target
|
||||
])
|
||||
def test_a_malformed_folder_is_rejected_with_an_explanation(client, headers, bad):
|
||||
"""Rejected at the API, where the message can say why, rather than on the
|
||||
endpoint where the failure would name a missing tool instead."""
|
||||
created = _create(client, headers, f'bad-{abs(hash(bad))}',
|
||||
workingdirectory=bad)
|
||||
|
||||
assert created.status_code == 400, created.get_json()
|
||||
assert 'workingdirectory' in created.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_folder_can_be_cleared_back_to_the_default(client, headers):
|
||||
"""Reverting to staging must be reachable; otherwise a mistyped pin is
|
||||
permanent short of deleting and re-uploading the script."""
|
||||
scriptid = _create(client, headers, 'clearable',
|
||||
workingdirectory=r'C:\Tools').get_json()['data']['scriptid']
|
||||
|
||||
updated = client.put(f'/api/scripts/{scriptid}', headers=headers,
|
||||
json={'workingdirectory': ''})
|
||||
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert not updated.get_json()['data']['workingdirectory']
|
||||
|
||||
|
||||
def test_the_delivery_descriptor_always_carries_the_field(client, db, headers):
|
||||
"""Always present, even when empty, so an agent that received it and
|
||||
ignored it is distinguishable from one that never saw it."""
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='delivered', filename='script-1.ps1',
|
||||
sha256='a' * 64, workingdirectory=r'C:\Tools')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['workingdirectory'] == r'C:\Tools'
|
||||
|
||||
|
||||
def test_the_descriptor_sends_an_empty_string_when_unpinned(client, db, headers):
|
||||
from plugins.endpoints.api.routes import _resolve_run_script
|
||||
from shopdb.core.models.script_library import Script
|
||||
|
||||
script = Script(scriptname='unpinned', filename='script-2.ps1',
|
||||
sha256='b' * 64)
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
|
||||
descriptor, error = _resolve_run_script(f'script:{script.scriptid}')
|
||||
|
||||
assert error is None, error
|
||||
assert descriptor['workingdirectory'] == ''
|
||||
@@ -1,9 +1,9 @@
|
||||
"""What global search can reach, and what it must not return.
|
||||
|
||||
Two plugins own records that are NOT assets - USB devices and printed items -
|
||||
so the generic asset search cannot see them, and nothing else did either: those
|
||||
records were unreachable from search entirely while their list pages existed and
|
||||
their labels were printed on physical bins and sticks.
|
||||
A plugin may own records that are NOT assets, and the generic asset search
|
||||
cannot see those: a domain has to be added for each. The cases that used to
|
||||
stand here belonged to plugins this line does not bundle; what remains is the
|
||||
asset, application, knowledge-base and guide coverage every site gets.
|
||||
|
||||
The other half of this file is the retired-application rule. Hiding an article
|
||||
about a decommissioned application from the plugin's own listing while global
|
||||
@@ -40,102 +40,6 @@ def titles(client, query, resulttype=None):
|
||||
if resulttype is None or r['type'] == resulttype]
|
||||
|
||||
|
||||
# --- USB devices (usb plugin, not an asset) ---------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def usbdevices(db):
|
||||
"""One live device and one deactivated one, distinctive enough that only the
|
||||
USB searcher can match them."""
|
||||
from plugins.usb.models import USBDevice
|
||||
live = USBDevice(serialnumber='ZZSERIAL1234', label='Loaner stick 4',
|
||||
assetnumber='USB-0004', productname='Kingston DataTraveler',
|
||||
isactive=True)
|
||||
retired = USBDevice(serialnumber='ZZSERIAL9999', label='Dead stick',
|
||||
assetnumber='USB-0009', productname='Kingston DataTraveler',
|
||||
isactive=False)
|
||||
db.session.add_all([live, retired])
|
||||
db.session.commit()
|
||||
return {'live': live.usbdeviceid, 'retired': retired.usbdeviceid}
|
||||
|
||||
|
||||
def test_a_usb_device_is_found_by_serial(client, usbdevices):
|
||||
"""The serial is what is printed on the stick in someone's hand."""
|
||||
rows = [r for r in search(client, 'ZZSERIAL1234') if r['type'] == 'usb_device']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == usbdevices['live']
|
||||
assert rows[0]['url'] == f"/usb/{usbdevices['live']}"
|
||||
# An exact serial is the strongest possible match for this domain.
|
||||
assert rows[0]['relevance'] == 100
|
||||
|
||||
|
||||
def test_a_usb_device_is_found_by_label_and_asset_tag(client, usbdevices):
|
||||
assert 'Loaner stick 4' in titles(client, 'Loaner', 'usb_device')
|
||||
assert 'Loaner stick 4' in titles(client, 'USB-0004', 'usb_device')
|
||||
|
||||
|
||||
def test_a_deactivated_usb_device_is_not_found(client, usbdevices):
|
||||
assert titles(client, 'ZZSERIAL9999', 'usb_device') == []
|
||||
# And it does not ride along on a query that matches both devices.
|
||||
assert 'Dead stick' not in titles(client, 'Kingston', 'usb_device')
|
||||
|
||||
|
||||
def test_a_usb_holder_is_not_searchable_by_name(client, db):
|
||||
"""currentusername records who holds a device. Making search a way to list
|
||||
what a named person checked out is a different feature, deliberately not
|
||||
built into this searcher."""
|
||||
from plugins.usb.models import USBDevice
|
||||
db.session.add(USBDevice(serialnumber='ZZSERIAL5555', label='Held stick',
|
||||
currentusername='Distinctivesurname', isactive=True))
|
||||
db.session.commit()
|
||||
assert titles(client, 'Distinctivesurname', 'usb_device') == []
|
||||
|
||||
|
||||
# --- Printed items (printedparts plugin, not an asset) ----------------------
|
||||
|
||||
@pytest.fixture
|
||||
def printeditems(db):
|
||||
from plugins.printedparts.models import PrintedItem
|
||||
live = PrintedItem(itemcode='3DP-8001', gagelabtag='WJRP8001',
|
||||
itemname='Fixture clamp', itemdescription='Holds a part',
|
||||
binlocation='Bin 12', quantityonhand=4,
|
||||
lowstockthreshold=2, isactive=True)
|
||||
retired = PrintedItem(itemcode='3DP-8009', gagelabtag='WJRP8009',
|
||||
itemname='Obsolete clamp', quantityonhand=0,
|
||||
lowstockthreshold=1, isactive=False)
|
||||
db.session.add_all([live, retired])
|
||||
db.session.commit()
|
||||
return {'live': live.printeditemid, 'retired': retired.printeditemid}
|
||||
|
||||
|
||||
def test_a_printed_item_is_found_by_bin_code(client, printeditems):
|
||||
"""itemcode is the bin label someone reads off the shelf."""
|
||||
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == printeditems['live']
|
||||
assert rows[0]['url'] == f"/printedparts/{printeditems['live']}"
|
||||
assert rows[0]['relevance'] == 100
|
||||
|
||||
|
||||
def test_a_printed_item_is_found_by_gage_lab_tag(client, printeditems):
|
||||
rows = [r for r in search(client, 'WJRP8001') if r['type'] == 'printed_item']
|
||||
assert [r['title'] for r in rows] == ['Fixture clamp']
|
||||
|
||||
|
||||
def test_a_printed_item_is_found_by_name(client, printeditems):
|
||||
assert 'Fixture clamp' in titles(client, 'Fixture clamp', 'printed_item')
|
||||
|
||||
|
||||
def test_a_printed_item_subtitle_locates_it(client, printeditems):
|
||||
"""A hit is only useful if it says where to go and get the part."""
|
||||
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
|
||||
assert 'Bin 12' in rows[0]['subtitle']
|
||||
|
||||
|
||||
def test_a_deactivated_printed_item_is_not_found(client, printeditems):
|
||||
assert titles(client, '3DP-8009', 'printed_item') == []
|
||||
assert 'Obsolete clamp' not in titles(client, 'clamp', 'printed_item')
|
||||
|
||||
|
||||
# --- The retired-application rule reaches global search too ----------------
|
||||
|
||||
@pytest.fixture
|
||||
@@ -180,7 +84,7 @@ def test_global_search_hides_a_retired_application_itself(client, kbarticles):
|
||||
def taggedmachine(client, db, auth_headers):
|
||||
"""A machine carrying both optional identifiers.
|
||||
|
||||
A MACHINE on purpose, not a measuring tool: gaugelabreference was searchable
|
||||
A MACHINE on purpose: gaugelabreference was searchable
|
||||
only through the measuring-tools searcher, so the same tag on a machine
|
||||
matched nothing even though Settings offers the identifier for machines.
|
||||
"""
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"""Search consumes get_asset_presentation for plugin-owned routes (ADR-010).
|
||||
|
||||
Core no longer hardcodes a plugin's search route once the plugin declares one:
|
||||
measuring tools (which declare a by-asset presentation route) link through it,
|
||||
while a type that has not declared falls back to the legacy id-keyed route.
|
||||
a type with a declared by-asset route links through it, while a type that has
|
||||
not declared falls back to the legacy id-keyed route.
|
||||
|
||||
The declaring plugin here is a STUB, not a bundled one. No plugin on this line
|
||||
implements get_asset_presentation, and a test that needed one would either have
|
||||
to be deleted - leaving the core consumption path untested - or pinned to
|
||||
whichever plugin happened to declare it that month. The hook is part of the
|
||||
plugin contract a receiving site writes against, so what is under test is core's
|
||||
handling of a declaration, not any particular declarer.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from shopdb.core.api import search as search_module
|
||||
from shopdb.core.api.search import _get_asset_result
|
||||
|
||||
|
||||
@@ -19,21 +27,24 @@ def _fake_asset(assettype, assetid, **extra):
|
||||
assetnumber=f'AN{assetid}',
|
||||
location=None,
|
||||
machine=None, computer=None, network_device=None, printer=None,
|
||||
measuringtool=None,
|
||||
)
|
||||
for key, value in extra.items():
|
||||
setattr(asset, key, value)
|
||||
return asset
|
||||
|
||||
|
||||
def test_measuring_tool_uses_declared_presentation_route(app):
|
||||
def test_a_declared_presentation_route_is_used(app, monkeypatch):
|
||||
"""A declared by-asset route is used, keyed on the core assetid."""
|
||||
monkeypatch.setattr(
|
||||
search_module, '_asset_presentation_map',
|
||||
lambda: {'printer': '/printers/by-asset/{assetid}'})
|
||||
with app.app_context():
|
||||
tool = _fake_asset(
|
||||
'measuring_tool', 641,
|
||||
measuringtool=SimpleNamespace(measuringtoolid=99))
|
||||
result = _get_asset_result(tool, 'x')
|
||||
assert result['url'] == '/measuringtools/by-asset/641'
|
||||
printer = _fake_asset(
|
||||
'printer', 641, printer=SimpleNamespace(printerid=99))
|
||||
result = _get_asset_result(printer, 'x')
|
||||
# The core assetid, NOT the plugin id: a by-asset route resolves the plugin
|
||||
# row itself, so handing it the plugin id opens the wrong record or none.
|
||||
assert result['url'] == '/printers/by-asset/641'
|
||||
assert result['id'] == 641
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers):
|
||||
else:
|
||||
path.write_text(svg)
|
||||
|
||||
resp = None
|
||||
try:
|
||||
resp = client.get('/api/applications/image/probe-headers.svg', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
@@ -54,4 +55,8 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers):
|
||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
finally:
|
||||
import os
|
||||
# The streamed file response keeps the handle open, and Windows refuses
|
||||
# to delete a file that is still open. Release it before cleaning up.
|
||||
if resp is not None:
|
||||
resp.close()
|
||||
os.remove(path)
|
||||
|
||||
@@ -171,19 +171,17 @@ def test_defaults_contain_label_target_keys():
|
||||
"""build_default_settings seeds the per-type label targets + label defaults."""
|
||||
by_key = _defaults_by_key()
|
||||
for key in ('qr_target_machine', 'qr_target_computer',
|
||||
'qr_target_network_device', 'qr_target_measuring_tool'):
|
||||
'qr_target_network_device', 'qr_target_printer'):
|
||||
assert key in by_key, f'missing default {key}'
|
||||
assert by_key[key]['value'] == ''
|
||||
assert by_key[key]['category'] == 'printing'
|
||||
assert '{locationcode}' in by_key['qr_target_measuring_tool']['description']
|
||||
assert by_key['label_default_style']['value'] == 'card'
|
||||
assert by_key['label_default_codetype']['value'] == 'qr'
|
||||
assert by_key['label_default_style']['category'] == 'printing'
|
||||
assert by_key['label_default_codetype']['category'] == 'printing'
|
||||
# Per-type default encode mode: machines -> their machine number,
|
||||
# measuring tools -> inspection location, the rest -> asset page.
|
||||
# Per-type default encode mode: machines -> their machine number, the rest
|
||||
# -> asset page.
|
||||
assert by_key['label_default_encodes_machine']['value'] == 'assetnumber'
|
||||
assert by_key['label_default_encodes_measuring_tool']['value'] == 'location'
|
||||
for assettype in ('computer', 'printer', 'network_device'):
|
||||
assert by_key[f'label_default_encodes_{assettype}']['value'] == 'assetpage'
|
||||
assert by_key[f'label_default_encodes_{assettype}']['category'] == 'printing'
|
||||
|
||||
@@ -1,27 +1,36 @@
|
||||
"""Tests for plugin-declared settings (contract 0.16.0).
|
||||
|
||||
Three regressions are pinned here, all found on the 3D-parts kiosk label
|
||||
prefix:
|
||||
Three regressions are pinned here:
|
||||
|
||||
1. The kiosk runs logged out, so a setting it renders must be readable by an
|
||||
anonymous caller when the owning plugin declares it public - and only then.
|
||||
1. A page that runs logged out must be able to read a setting the owning plugin
|
||||
declares public - and only that one. The collecting script that reads the
|
||||
backup interval has no credential at all when it reads it.
|
||||
2. A save that creates the row for the first time must file it under the
|
||||
declared category, or the plugin's own settings page (which lists by
|
||||
category) stops seeing the value and the save looks lost.
|
||||
3. Settings declared by a later plugin version must reach a site that installed
|
||||
an earlier one; on_install/on_enable fire only on a state transition, so the
|
||||
upgrade pass has to seed them.
|
||||
|
||||
The plugin used here is `backups`, because it declares BOTH a public key and
|
||||
private ones. What is under test is the declared-default mechanism, not this
|
||||
plugin: any plugin declaring one of each would do.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
KIOSK_KEY = 'printedparts_label_prefix'
|
||||
PRIVATE_KEY = 'printedparts_alert_email'
|
||||
PLUGIN = 'backups'
|
||||
CATEGORY = 'backups'
|
||||
# Public: read by the collecting script before it has any credential.
|
||||
PUBLIC_KEY = 'backups_intervalhours'
|
||||
PUBLIC_DEFAULT = '24'
|
||||
# Private: a UNC path is internal topology.
|
||||
PRIVATE_KEY = 'backups_shareroot'
|
||||
|
||||
|
||||
def _plugin(app, name='printedparts'):
|
||||
def _plugin(app, name=PLUGIN):
|
||||
pm = app.extensions.get('plugin_manager')
|
||||
plugin = pm.loader.get_loaded_plugin(name) if pm else None
|
||||
if plugin is None:
|
||||
@@ -29,47 +38,47 @@ def _plugin(app, name='printedparts'):
|
||||
return pm, plugin
|
||||
|
||||
|
||||
def test_plugin_declares_the_kiosk_prefix_public(app):
|
||||
def test_plugin_declares_exactly_one_key_public(app):
|
||||
_, plugin = _plugin(app)
|
||||
declared = {entry['key']: entry for entry in plugin.get_settings_defaults()}
|
||||
assert declared[KIOSK_KEY]['public'] is True
|
||||
assert declared[KIOSK_KEY]['category'] == 'printedparts'
|
||||
assert declared[PUBLIC_KEY]['public'] is True
|
||||
assert declared[PUBLIC_KEY]['category'] == CATEGORY
|
||||
# Everything else the plugin owns stays behind auth.
|
||||
assert not any(entry.get('public') for key, entry in declared.items()
|
||||
if key != KIOSK_KEY)
|
||||
if key != PUBLIC_KEY)
|
||||
|
||||
|
||||
def test_anon_can_read_a_public_plugin_setting(client, db):
|
||||
"""The kiosk reads this with no token; a 404 leaves the prefix blank."""
|
||||
Setting.set(KIOSK_KEY, 'WJ', valuetype='string', category='printedparts')
|
||||
"""The collector reads this with no token; a 404 leaves it on its fallback."""
|
||||
Setting.set(PUBLIC_KEY, '6', valuetype='integer', category=CATEGORY)
|
||||
|
||||
resp = client.get(f'/api/settings/{KIOSK_KEY}')
|
||||
resp = client.get(f'/api/settings/{PUBLIC_KEY}')
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['value'] == 'WJ'
|
||||
assert str(resp.get_json()['data']['value']) == '6'
|
||||
|
||||
|
||||
def test_anon_cannot_read_a_nonpublic_plugin_setting(client, db):
|
||||
Setting.set(PRIVATE_KEY, 'lead@example.com', valuetype='string',
|
||||
category='printedparts')
|
||||
Setting.set(PRIVATE_KEY, r'\\fileserver\secret', valuetype='string',
|
||||
category=CATEGORY)
|
||||
|
||||
resp = client.get(f'/api/settings/{PRIVATE_KEY}')
|
||||
|
||||
assert resp.status_code == 404
|
||||
body = resp.get_data(as_text=True)
|
||||
assert 'lead@example.com' not in body
|
||||
assert 'fileserver' not in body
|
||||
|
||||
|
||||
def test_anon_list_includes_public_plugin_settings_only(client, db):
|
||||
Setting.set(KIOSK_KEY, 'WJ', valuetype='string', category='printedparts')
|
||||
Setting.set(PRIVATE_KEY, 'lead@example.com', valuetype='string',
|
||||
category='printedparts')
|
||||
Setting.set(PUBLIC_KEY, '6', valuetype='integer', category=CATEGORY)
|
||||
Setting.set(PRIVATE_KEY, r'\\fileserver\secret', valuetype='string',
|
||||
category=CATEGORY)
|
||||
|
||||
resp = client.get('/api/settings?category=printedparts')
|
||||
resp = client.get(f'/api/settings?category={CATEGORY}')
|
||||
|
||||
assert resp.status_code == 200
|
||||
keys = {row['key'] for row in resp.get_json()['data']}
|
||||
assert keys == {KIOSK_KEY}
|
||||
assert keys == {PUBLIC_KEY}
|
||||
|
||||
|
||||
def test_first_save_files_the_key_under_its_declared_category(client, db,
|
||||
@@ -80,19 +89,20 @@ def test_first_save_files_the_key_under_its_declared_category(client, db,
|
||||
so the PUT creates it. Filed under a placeholder category it would vanish
|
||||
from the page's category-filtered reload - the "it does not save" bug.
|
||||
"""
|
||||
assert Setting.query.filter_by(key=KIOSK_KEY).first() is None
|
||||
assert Setting.query.filter_by(key=PRIVATE_KEY).first() is None
|
||||
|
||||
resp = client.put(f'/api/settings/{KIOSK_KEY}', json={'value': 'WJ'},
|
||||
resp = client.put(f'/api/settings/{PRIVATE_KEY}',
|
||||
json={'value': r'\\fileserver\backups'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
setting = Setting.query.filter_by(key=KIOSK_KEY).first()
|
||||
assert setting.category == 'printedparts'
|
||||
setting = Setting.query.filter_by(key=PRIVATE_KEY).first()
|
||||
assert setting.category == CATEGORY
|
||||
|
||||
reload = client.get('/api/settings?category=printedparts',
|
||||
reload = client.get(f'/api/settings?category={CATEGORY}',
|
||||
headers=auth_headers)
|
||||
values = {row['key']: row['value'] for row in reload.get_json()['data']}
|
||||
assert values[KIOSK_KEY] == 'WJ'
|
||||
assert values[PRIVATE_KEY] == r'\\fileserver\backups'
|
||||
|
||||
|
||||
def test_first_save_of_a_core_key_keeps_its_core_category(client, db,
|
||||
@@ -116,33 +126,33 @@ def test_undeclared_key_still_upserts(client, db, auth_headers):
|
||||
def test_upgrade_seeds_settings_missing_from_an_existing_site(app, db):
|
||||
"""`flask plugin upgrade-all` reaches a site that installed earlier."""
|
||||
pm, _ = _plugin(app)
|
||||
assert Setting.query.filter_by(key=KIOSK_KEY).first() is None
|
||||
assert Setting.query.filter_by(key=PUBLIC_KEY).first() is None
|
||||
|
||||
pm.sync_all_plugin_settings()
|
||||
|
||||
setting = Setting.query.filter_by(key=KIOSK_KEY).first()
|
||||
setting = Setting.query.filter_by(key=PUBLIC_KEY).first()
|
||||
assert setting is not None
|
||||
assert setting.category == 'printedparts'
|
||||
assert setting.value == ''
|
||||
assert setting.category == CATEGORY
|
||||
assert str(setting.value) == PUBLIC_DEFAULT
|
||||
|
||||
|
||||
def test_sync_never_overwrites_a_configured_value(app, db):
|
||||
pm, _ = _plugin(app)
|
||||
Setting.set(KIOSK_KEY, 'WJ', valuetype='string', category='printedparts')
|
||||
Setting.set(PUBLIC_KEY, '6', valuetype='integer', category=CATEGORY)
|
||||
|
||||
pm.sync_all_plugin_settings()
|
||||
|
||||
assert Setting.query.filter_by(key=KIOSK_KEY).first().value == 'WJ'
|
||||
assert str(Setting.query.filter_by(key=PUBLIC_KEY).first().value) == '6'
|
||||
|
||||
|
||||
def test_sync_rehomes_a_row_left_in_the_placeholder_category(app, db):
|
||||
"""Repairs rows an earlier save created before the key was declared."""
|
||||
pm, _ = _plugin(app)
|
||||
Setting.set(PRIVATE_KEY, 'lead@example.com', valuetype='string',
|
||||
Setting.set(PRIVATE_KEY, r'\\fileserver\backups', valuetype='string',
|
||||
category='plugin')
|
||||
|
||||
pm.sync_all_plugin_settings()
|
||||
|
||||
setting = Setting.query.filter_by(key=PRIVATE_KEY).first()
|
||||
assert setting.category == 'printedparts'
|
||||
assert setting.value == 'lead@example.com'
|
||||
assert setting.category == CATEGORY
|
||||
assert setting.value == r'\\fileserver\backups'
|
||||
|
||||
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
|
||||
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
242
tests/test_core/test_shopdbbackups_finalize.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""The post-restore schema upgrade: who runs it, and what happens when it fails.
|
||||
|
||||
A restore loads the dump in the Flask-less backup sidecar and leaves a sentinel,
|
||||
because only the api container can run the migrations. These tests pin the two
|
||||
properties that failure taught us to care about: the upgrade must not depend on
|
||||
a browser staying open, and it must never run twice at once.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.api import shopdbbackups
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backups_root(tmp_path, monkeypatch):
|
||||
"""Point the backup routes at a real, empty directory."""
|
||||
root = tmp_path / 'backups'
|
||||
root.mkdir()
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV, str(root))
|
||||
return root
|
||||
|
||||
|
||||
def _arm(root, setname='wj-2026-09-06T120000Z'):
|
||||
(root / shopdbbackups.UPGRADE_SENTINEL).write_text(setname, encoding='utf-8')
|
||||
return setname
|
||||
|
||||
|
||||
def test_only_one_claimant_wins_a_pending_upgrade(app, backups_root):
|
||||
"""Two workers racing the same sentinel must not both run Alembic."""
|
||||
setname = _arm(backups_root)
|
||||
with app.app_context():
|
||||
first = shopdbbackups._claim_upgrade()
|
||||
second = shopdbbackups._claim_upgrade()
|
||||
|
||||
assert first == setname
|
||||
assert second is None, 'a second worker claimed an upgrade already in flight'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists()
|
||||
assert (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
|
||||
|
||||
def test_claiming_nothing_is_not_an_error(app, backups_root):
|
||||
with app.app_context():
|
||||
assert shopdbbackups._claim_upgrade() is None
|
||||
|
||||
|
||||
def test_a_successful_finalize_clears_the_claim_and_says_done(app, backups_root,
|
||||
monkeypatch):
|
||||
setname = _arm(backups_root)
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', lambda: None)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is True
|
||||
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'done'
|
||||
assert status['setname'] == setname
|
||||
|
||||
|
||||
def test_a_failed_upgrade_parks_itself_instead_of_retrying_forever(app,
|
||||
backups_root,
|
||||
monkeypatch):
|
||||
"""A failing migration must not be re-armed automatically.
|
||||
|
||||
Putting the sentinel back would run the same broken chain on every poll for
|
||||
as long as the container lives.
|
||||
"""
|
||||
_arm(backups_root)
|
||||
|
||||
def _boom():
|
||||
raise RuntimeError('Can\'t locate revision identified by abc123')
|
||||
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade', _boom)
|
||||
|
||||
with app.app_context():
|
||||
claimed = shopdbbackups._claim_upgrade()
|
||||
assert shopdbbackups._finalize_claimed(claimed) is False
|
||||
|
||||
assert (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'a failed upgrade re-armed itself and will retry forever'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_CLAIM).exists()
|
||||
with app.app_context():
|
||||
status = shopdbbackups._read_status('restore')
|
||||
assert status and status['phase'] == 'failed'
|
||||
|
||||
|
||||
def test_the_endpoint_does_not_run_the_upgrade_itself(app, client, auth_headers,
|
||||
backups_root, monkeypatch):
|
||||
"""Alembic in a request can outrun the worker timeout and be killed mid-chain."""
|
||||
_arm(backups_root)
|
||||
ran = []
|
||||
monkeypatch.setattr(shopdbbackups, '_run_schema_upgrade',
|
||||
lambda: ran.append(True))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert ran == [], 'the request ran the schema upgrade inline'
|
||||
assert response.get_json()['data']['queued'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).exists(), \
|
||||
'the endpoint consumed the sentinel the watcher needs to claim'
|
||||
|
||||
|
||||
def test_the_endpoint_re_arms_a_failed_upgrade(app, client, auth_headers,
|
||||
backups_root):
|
||||
"""Retrying a failed upgrade is what this endpoint is for now."""
|
||||
(backups_root / shopdbbackups.UPGRADE_FAILED).write_text('wj-set',
|
||||
encoding='utf-8')
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['retried'] is True
|
||||
assert (backups_root / shopdbbackups.UPGRADE_SENTINEL).read_text() == 'wj-set'
|
||||
assert not (backups_root / shopdbbackups.UPGRADE_FAILED).exists()
|
||||
|
||||
|
||||
def test_nothing_pending_reports_nothing_pending(app, client, auth_headers,
|
||||
backups_root):
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['queued'] is False
|
||||
|
||||
|
||||
def test_an_unconfigured_deployment_refuses(app, client, auth_headers,
|
||||
tmp_path, monkeypatch):
|
||||
"""No store behind the feature means say so, not 500 on a missing path."""
|
||||
monkeypatch.setenv(shopdbbackups.BACKUPS_ROOT_ENV,
|
||||
str(tmp_path / 'nothing-here'))
|
||||
|
||||
response = client.post('/api/shopdbbackups/finalize-restore',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestPluginChainCompatibility:
|
||||
"""The restore gate has to see plugin chains, not just the core one.
|
||||
|
||||
The failure it exists for: roll code back to an older plugin revision and
|
||||
the CORE head can be identical, so a core-only gate passes a dump whose
|
||||
plugin tables the code has never heard of. It loads - destroying what was
|
||||
there - and only then dies in 'flask plugin upgrade-all'.
|
||||
"""
|
||||
|
||||
def _manifest(self, core_rev, plugins=None):
|
||||
manifest = {'datarev': core_rev, 'schemarev': {'core': [core_rev]}}
|
||||
if plugins is not None:
|
||||
manifest['dataplugins'] = plugins
|
||||
return manifest
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
assert known, 'expected this build to know its own core revisions'
|
||||
return sorted(known)[0]
|
||||
|
||||
def _real_plugin_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_plugin_revisions()
|
||||
assert known, 'expected this build to have plugin migration chains'
|
||||
name = sorted(known)[0]
|
||||
return name, sorted(known[name])[0]
|
||||
|
||||
def test_a_set_from_a_newer_plugin_chain_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, _ = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: 'thisrevisiondoesnotexist'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False, \
|
||||
'a dump carrying unknown plugin data was called restorable'
|
||||
|
||||
def test_a_set_whose_plugin_chains_are_known_is_allowed(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest(core, {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is True
|
||||
|
||||
def test_a_plugin_this_build_does_not_have_is_refused(self, app):
|
||||
core = self._real_core_rev(app)
|
||||
manifest = self._manifest(core, {'nosuchplugin': 'somerevision'})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
def test_a_legacy_set_without_the_stamp_keeps_its_old_verdict(self, app):
|
||||
"""Sets taken before dataplugins existed must not become unrestorable."""
|
||||
core = self._real_core_rev(app)
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(self._manifest(core), {}) is True
|
||||
assert shopdbbackups._is_compatible(
|
||||
self._manifest(core, {}), {}) is True
|
||||
|
||||
def test_the_core_gate_still_refuses_an_unknown_core_revision(self, app):
|
||||
name, rev = self._real_plugin_rev(app)
|
||||
manifest = self._manifest('unknowncorerevision', {name: rev})
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(manifest, {}) is False
|
||||
|
||||
|
||||
class TestDatarevEvidence:
|
||||
"""An unreadable data revision must not be badged restorable."""
|
||||
|
||||
def _real_core_rev(self, app):
|
||||
with app.app_context():
|
||||
known = shopdbbackups._known_core_revisions()
|
||||
return sorted(known)[0]
|
||||
|
||||
def test_an_empty_datarev_is_no_evidence_not_a_legacy_set(self, app):
|
||||
"""backup.sh discards the mysql error, so datarev can be ''.
|
||||
|
||||
Falling back to the code-to-code head check there would badge the set
|
||||
compatible using the very comparison datarev was introduced to replace.
|
||||
"""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'datarev': '', 'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is False
|
||||
|
||||
def test_a_manifest_with_no_datarev_key_still_falls_back(self, app):
|
||||
"""A genuine legacy set predates the field entirely and keeps working."""
|
||||
core = self._real_core_rev(app)
|
||||
manifest = {'schemarev': {'core': [core]}}
|
||||
|
||||
with app.app_context():
|
||||
assert shopdbbackups._is_compatible(
|
||||
manifest, {'core': [core]}) is True
|
||||
298
tests/test_core/test_user_scopes.py
Normal file
298
tests/test_core/test_user_scopes.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Prebuilt user scopes: derived grants, seeding, and the Reporter guard.
|
||||
|
||||
The guard tests are the important ones. The scope dropdown greys out endpoint
|
||||
actions for a Reporter, but a greyed checkbox stops nobody with an HTTP client,
|
||||
and Reporter was specified as holding no action capability even if expressly
|
||||
permitted. These assert that the promise holds against the API rather than
|
||||
against the UI.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import (
|
||||
Role, Permission, scope_permission_names, PREBUILT_SCOPES,
|
||||
SCOPE_ENDPOINT_ACTIONS, SCOPE_NEVER_GRANT)
|
||||
|
||||
|
||||
def _core_catalog():
|
||||
return list(Permission.CORE_PERMISSIONS)
|
||||
|
||||
|
||||
def test_user_editor_checkboxes_match_the_server_action_list():
|
||||
"""The Vue checkbox list must match SCOPE_ENDPOINT_ACTIONS exactly.
|
||||
|
||||
These two lists are maintained by hand in different languages, so they drift
|
||||
silently and in both directions. A name in Python but not in Vue is a grant
|
||||
no admin can actually give - the feature ships dark. A name in Vue but not in
|
||||
Python is worse: the checkbox appears, the admin ticks it believing access
|
||||
was granted, and the server refuses to store it.
|
||||
|
||||
This is the guard the SCOPE_ENDPOINT_ACTIONS comment claims exists. It did
|
||||
not, which is exactly how endpoints.operatorreports reached a running
|
||||
container with no way to grant it.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
source = Path(__file__).resolve().parents[2].joinpath(
|
||||
'frontend', 'src', 'views', 'settings', 'UsersList.vue').read_text(
|
||||
encoding='utf-8')
|
||||
|
||||
block = re.search(
|
||||
r'const endpointActionPermissions = \[(.*?)\]', source, re.S)
|
||||
assert block, 'endpointActionPermissions not found in UsersList.vue'
|
||||
infrontend = set(re.findall(r"name:\s*'([^']+)'", block.group(1)))
|
||||
|
||||
assert infrontend == set(SCOPE_ENDPOINT_ACTIONS), (
|
||||
'User editor checkboxes and SCOPE_ENDPOINT_ACTIONS disagree. '
|
||||
f'Only in Vue: {sorted(infrontend - set(SCOPE_ENDPOINT_ACTIONS))}. '
|
||||
f'Only in Python: {sorted(set(SCOPE_ENDPOINT_ACTIONS) - infrontend)}.')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived grant lists
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize('scope', ['operator', 'technician', 'reporter'])
|
||||
def test_scope_never_grants_the_excluded_permissions(scope, app):
|
||||
"""No scope may leak an excluded permission, whatever the catalog holds.
|
||||
|
||||
Guards the derivation itself: the lists are computed by verb, so a new
|
||||
permission named with an unexpected verb could otherwise slip into a scope
|
||||
silently.
|
||||
"""
|
||||
with app.app_context():
|
||||
granted = set(scope_permission_names(scope))
|
||||
assert not (granted & SCOPE_NEVER_GRANT)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('scope', ['operator', 'technician', 'reporter'])
|
||||
def test_no_scope_can_act_on_endpoints_through_its_role(scope, app):
|
||||
"""Endpoint actions are per-user grants, never role grants.
|
||||
|
||||
If a scope ever carried one, the per-user checkbox would stop being the
|
||||
control point and the Reporter guard could be bypassed by role alone.
|
||||
"""
|
||||
with app.app_context():
|
||||
granted = set(scope_permission_names(scope))
|
||||
assert not (granted & set(SCOPE_ENDPOINT_ACTIONS))
|
||||
|
||||
|
||||
def test_operator_can_edit_but_never_delete(app):
|
||||
"""Deletion stays with admins - the defining line for Operator."""
|
||||
with app.app_context():
|
||||
granted = scope_permission_names('operator', catalog=_core_catalog())
|
||||
assert 'assets.create' in granted
|
||||
assert 'assets.edit' in granted
|
||||
assert not [n for n in granted if n.endswith('.delete')]
|
||||
|
||||
|
||||
def test_technician_holds_no_record_mutation(app):
|
||||
"""Technician reads records; endpoint work comes from per-user grants."""
|
||||
with app.app_context():
|
||||
granted = scope_permission_names('technician', catalog=_core_catalog())
|
||||
assert 'assets.view' in granted
|
||||
assert 'assets.create' not in granted
|
||||
assert 'assets.edit' not in granted
|
||||
|
||||
|
||||
def test_reporter_reads_and_exports(app):
|
||||
with app.app_context():
|
||||
granted = scope_permission_names('reporter', catalog=_core_catalog())
|
||||
assert 'reports.view' in granted
|
||||
assert 'reports.export' in granted
|
||||
|
||||
|
||||
def test_every_scope_sees_the_user_directory_but_cannot_manage_it(app):
|
||||
"""users.view is granted; creating and editing accounts is admin-only."""
|
||||
with app.app_context():
|
||||
for scope in ('operator', 'technician', 'reporter'):
|
||||
granted = scope_permission_names(scope, catalog=_core_catalog())
|
||||
assert 'users.view' in granted, scope
|
||||
assert 'users.create' not in granted, scope
|
||||
assert 'users.edit' not in granted, scope
|
||||
assert 'users.delete' not in granted, scope
|
||||
|
||||
|
||||
def test_scope_permission_names_rejects_unknown_scope(app):
|
||||
with app.app_context():
|
||||
with pytest.raises(ValueError):
|
||||
scope_permission_names('wizard')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_seedscopes_creates_roles_and_is_idempotent(app, db):
|
||||
"""A second run creates nothing and grants nothing new."""
|
||||
with app.app_context():
|
||||
Permission.seed()
|
||||
db.session.commit()
|
||||
|
||||
first = Role.seedscopes()
|
||||
db.session.commit()
|
||||
assert set(first['created']) == {n for n, _, _ in PREBUILT_SCOPES}
|
||||
|
||||
second = Role.seedscopes()
|
||||
db.session.commit()
|
||||
assert second['created'] == []
|
||||
assert second['granted'] == {}
|
||||
|
||||
|
||||
def test_seedscopes_does_not_revoke_custom_grants(app, db):
|
||||
"""Re-running must not undo a deliberate site customization.
|
||||
|
||||
The seed is additive by design: a revoking seed would silently strip an
|
||||
extra grant an admin had chosen, and it would do so on deploy, where nobody
|
||||
is watching.
|
||||
"""
|
||||
with app.app_context():
|
||||
Permission.seed()
|
||||
db.session.commit()
|
||||
Role.seedscopes()
|
||||
db.session.commit()
|
||||
|
||||
technician = Role.query.filter_by(rolename='technician').one()
|
||||
extra = Permission.query.filter_by(name='assets.edit').one()
|
||||
technician.permissions.append(extra)
|
||||
db.session.commit()
|
||||
|
||||
Role.seedscopes()
|
||||
db.session.commit()
|
||||
|
||||
technician = Role.query.filter_by(rolename='technician').one()
|
||||
assert 'assets.edit' in {p.name for p in technician.permissions}
|
||||
|
||||
|
||||
def test_seedscopes_grants_survive_a_reporter_role(app, db):
|
||||
with app.app_context():
|
||||
Permission.seed()
|
||||
db.session.commit()
|
||||
Role.seedscopes()
|
||||
db.session.commit()
|
||||
|
||||
reporter = Role.query.filter_by(rolename='reporter').one()
|
||||
held = {p.name for p in reporter.permissions}
|
||||
assert 'reports.export' in held
|
||||
assert not (held & set(SCOPE_ENDPOINT_ACTIONS))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The write-path guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_scopes(app, db):
|
||||
with app.app_context():
|
||||
Permission.seed()
|
||||
db.session.commit()
|
||||
Role.seedscopes()
|
||||
db.session.commit()
|
||||
return {
|
||||
name: Role.query.filter_by(rolename=name).one().roleid
|
||||
for name, _, _ in PREBUILT_SCOPES
|
||||
}
|
||||
|
||||
|
||||
def test_create_reporter_with_endpoint_action_is_refused(client, auth_headers,
|
||||
app, db):
|
||||
"""The whole point: a Reporter cannot be created holding a restart grant."""
|
||||
ids = _seed_scopes(app, db)
|
||||
response = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'scopereporter', 'email': 'scopereporter@test.local',
|
||||
'password': 'TestPass123!', 'roles': [ids['reporter']],
|
||||
'directpermissions': ['endpoints.restart'],
|
||||
})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'reporter' in message.lower()
|
||||
|
||||
from shopdb.core.models import User
|
||||
with app.app_context():
|
||||
assert User.query.filter_by(username='scopereporter').first() is None
|
||||
|
||||
|
||||
def test_create_technician_with_endpoint_action_is_allowed(client, auth_headers,
|
||||
app, db):
|
||||
ids = _seed_scopes(app, db)
|
||||
response = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'scopetech', 'email': 'scopetech@test.local',
|
||||
'password': 'TestPass123!', 'roles': [ids['technician']],
|
||||
'directpermissions': ['endpoints.restart'],
|
||||
})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_create_reporter_without_actions_is_allowed(client, auth_headers,
|
||||
app, db):
|
||||
ids = _seed_scopes(app, db)
|
||||
response = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'plainreporter', 'email': 'plainreporter@test.local',
|
||||
'password': 'TestPass123!', 'roles': [ids['reporter']],
|
||||
'directpermissions': [],
|
||||
})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_demoting_a_technician_to_reporter_keeps_no_stale_grant(
|
||||
client, auth_headers, app, db):
|
||||
"""The partial-payload case, and the easiest one to get wrong.
|
||||
|
||||
The request changes ROLES only and never mentions directpermissions, so a
|
||||
guard that inspected just the supplied fields would see no endpoint action
|
||||
and allow it - leaving a Reporter holding restart, the exact state the
|
||||
scope promises cannot exist. The guard checks effective state instead.
|
||||
"""
|
||||
ids = _seed_scopes(app, db)
|
||||
created = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'demoteme', 'email': 'demoteme@test.local',
|
||||
'password': 'TestPass123!', 'roles': [ids['technician']],
|
||||
'directpermissions': ['endpoints.restart'],
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
userid = created.get_json()['data']['userid']
|
||||
|
||||
response = client.put(f'/api/users/{userid}', headers=auth_headers,
|
||||
json={'roles': [ids['reporter']]})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
from shopdb.core.models import User
|
||||
with app.app_context():
|
||||
user = db.session.get(User, userid)
|
||||
assert [r.rolename for r in user.roles] == ['technician']
|
||||
assert 'endpoints.restart' in user.getdirectpermissionnames()
|
||||
|
||||
|
||||
def test_adding_an_action_to_an_existing_reporter_is_refused(
|
||||
client, auth_headers, app, db):
|
||||
"""The mirror case: grants change, roles unmentioned."""
|
||||
ids = _seed_scopes(app, db)
|
||||
created = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'staticreporter', 'email': 'staticreporter@test.local',
|
||||
'password': 'TestPass123!', 'roles': [ids['reporter']],
|
||||
})
|
||||
assert created.status_code == 201, created.get_json()
|
||||
userid = created.get_json()['data']['userid']
|
||||
|
||||
response = client.put(
|
||||
f'/api/users/{userid}', headers=auth_headers,
|
||||
json={'directpermissions': ['endpoints.runscript']})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
from shopdb.core.models import User
|
||||
with app.app_context():
|
||||
user = db.session.get(User, userid)
|
||||
assert user.getdirectpermissionnames() == []
|
||||
|
||||
|
||||
def test_stacking_technician_onto_reporter_cannot_launder_a_grant(
|
||||
client, auth_headers, app, db):
|
||||
"""Reporter denies actions even when a permissive scope sits beside it."""
|
||||
ids = _seed_scopes(app, db)
|
||||
response = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'stacked', 'email': 'stacked@test.local',
|
||||
'password': 'TestPass123!',
|
||||
'roles': [ids['reporter'], ids['technician']],
|
||||
'directpermissions': ['endpoints.corptools'],
|
||||
})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
@@ -47,3 +47,85 @@ def test_role_color_create_list_update(client, auth_headers):
|
||||
json={'color': '#00aa55'})
|
||||
assert updated.status_code == 200, updated.get_json()
|
||||
assert updated.get_json()['data']['color'] == '#00aa55'
|
||||
|
||||
|
||||
def test_directpermissions_granted_without_a_role(client, auth_headers, app, db):
|
||||
"""A permission may be granted to one user without minting a role for it,
|
||||
and it must reach every surface that answers "may this person do X"."""
|
||||
from shopdb.core.models import Permission, User
|
||||
|
||||
with app.app_context():
|
||||
Permission.seed_entries([
|
||||
('endpoints.runscript', 'Push scripts', 'endpoints'),
|
||||
('endpoints.corptools', 'Corporate tool use', 'endpoints'),
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
created = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'bob', 'email': 'bob@test.local', 'password': 'hunter2!!',
|
||||
'roles': [], 'directpermissions': ['endpoints.runscript'],
|
||||
'sendwelcome': False,
|
||||
})
|
||||
assert created.status_code in (200, 201), created.get_json()
|
||||
payload = created.get_json()['data']
|
||||
userid = payload['userid']
|
||||
|
||||
assert payload['directpermissions'] == ['endpoints.runscript']
|
||||
assert 'endpoints.runscript' in payload['permissions']
|
||||
|
||||
with app.app_context():
|
||||
user = db.session.get(User, userid)
|
||||
assert user.haspermission('endpoints.runscript')
|
||||
assert not user.haspermission('endpoints.corptools')
|
||||
assert 'endpoints.runscript' in user.getpermissions()
|
||||
|
||||
revoked = client.put(f'/api/users/{userid}', headers=auth_headers,
|
||||
json={'directpermissions': []})
|
||||
assert revoked.status_code == 200, revoked.get_json()
|
||||
assert revoked.get_json()['data']['directpermissions'] == []
|
||||
|
||||
with app.app_context():
|
||||
user = db.session.get(User, userid)
|
||||
assert not user.haspermission('endpoints.runscript')
|
||||
|
||||
|
||||
def test_a_direct_grant_is_not_duplicated_when_a_role_supplies_it_too(
|
||||
client, auth_headers, app, db):
|
||||
"""Holding a permission both ways is one capability, not two. getpermissions()
|
||||
feeds the API token scope ceiling, so a duplicate would be a visible defect."""
|
||||
from shopdb.core.models import Permission, Role, User
|
||||
|
||||
with app.app_context():
|
||||
Permission.seed_entries([
|
||||
('endpoints.updateapp', 'Update applications', 'endpoints'),
|
||||
])
|
||||
permission = Permission.query.filter_by(
|
||||
name='endpoints.updateapp').one()
|
||||
role = Role(rolename='updaters', description='app updaters')
|
||||
role.permissions = [permission]
|
||||
user = User(username='dana', email='dana@test.local',
|
||||
passwordhash='x', isactive=True)
|
||||
user.roles = [role]
|
||||
user.directpermissions = [permission]
|
||||
db.session.add_all([role, user])
|
||||
db.session.commit()
|
||||
userid = user.userid
|
||||
|
||||
with app.app_context():
|
||||
user = db.session.get(User, userid)
|
||||
granted = user.getpermissions()
|
||||
assert sorted(granted).count('endpoints.updateapp') == 1
|
||||
assert user.haspermission('endpoints.updateapp')
|
||||
|
||||
|
||||
def test_directpermissions_rejects_an_unknown_name(client, auth_headers):
|
||||
"""A typo must fail loudly. Dropping it would report success while granting
|
||||
nothing, and the admin would believe the capability was handed out."""
|
||||
created = client.post('/api/users', headers=auth_headers, json={
|
||||
'username': 'carl', 'email': 'carl@test.local', 'password': 'hunter2!!',
|
||||
'directpermissions': ['endpoints.runscripts'], 'sendwelcome': False,
|
||||
})
|
||||
assert created.status_code == 400, created.get_json()
|
||||
message = created.get_json()['data']['error']['message']
|
||||
assert 'endpoints.runscripts' in message
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from shopdb.extensions import db as _db
|
||||
|
||||
|
||||
REFERENCE = [
|
||||
('assettypes', 'assettype,description\nMachine,Shop floor\nMeasuring Tool,Gage lab\n'),
|
||||
('assettypes', 'assettype,description\nMachine,Shop floor\nPrinter,Office\n'),
|
||||
('assetstatuses', 'status,description\nActive,In service\nRetired,Out\n'),
|
||||
('locationtypes', 'locationtype\nBay\nRoom\n'),
|
||||
]
|
||||
@@ -45,7 +45,7 @@ def test_foreign_keys_resolve_by_name(db):
|
||||
result = import_csv(
|
||||
'assets',
|
||||
'assetnumber,name,assettypeid,statusid,locationid\n'
|
||||
'CMM-01,Zeiss,Measuring Tool,Active,Gage Lab\n',
|
||||
'PRN-01,HP,Printer,Active,Gage Lab\n',
|
||||
resolver=resolver, commit=True)
|
||||
assert result.ok, [str(p) for p in result.problems]
|
||||
|
||||
@@ -54,7 +54,7 @@ def test_foreign_keys_resolve_by_name(db):
|
||||
'join assettypes t on t.assettypeid = a.assettypeid '
|
||||
'join assetstatuses s on s.statusid = a.statusid '
|
||||
'join locations l on l.locationid = a.locationid')).fetchone()
|
||||
assert tuple(row) == ('Measuring Tool', 'Active', 'Gage Lab')
|
||||
assert tuple(row) == ('Printer', 'Active', 'Gage Lab')
|
||||
|
||||
|
||||
def test_numeric_ids_still_work(db):
|
||||
|
||||
393
tests/test_docs_index.py
Normal file
393
tests/test_docs_index.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""docs/INDEX.md is the router, and a router nobody checks is a router that lies.
|
||||
|
||||
The corpus is larger than any reader - person or agent - can hold, so the only
|
||||
thing that makes it usable is being able to find the minimum correct set of
|
||||
pages for a task and skip the rest. That needs two things to stay true, and
|
||||
neither survives on good intentions:
|
||||
|
||||
1. Every page declares what it is for and whether it is still current, in a
|
||||
`docmeta` block a machine can filter on without opening the page.
|
||||
2. docs/INDEX.md routes every current page, and quarantines the ones that are
|
||||
superseded or are only a record.
|
||||
|
||||
The failure this gate exists to stop is the quiet one: a page is added, nothing
|
||||
routes to it, and it rots unread - or worse, a page goes `historical` and keeps
|
||||
being followed because it is still sitting in a task row.
|
||||
|
||||
A second pass over the whole corpus added three more, because the first three
|
||||
turned out to be satisfiable by an index that was still useless:
|
||||
|
||||
3. Being NAMED somewhere in INDEX.md is not the same as being routed. A
|
||||
current page that appears only in a paragraph, or only in the Archive
|
||||
section's prose, is a page no task row sends anyone to. It must be in a
|
||||
row.
|
||||
4. The line counts have to be right, not roughly right. The cost column is
|
||||
the reason to trust the index over opening pages at random, and a corpus
|
||||
that is being cut shrinks pages constantly - so a stale number is the
|
||||
normal failure, not an exotic one.
|
||||
5. The two routers - INDEX.md and START-HERE.md - must not point at a file
|
||||
that is not there. Pages get merged and renamed; a dead link in the router
|
||||
is worse than a missing row, because the reader trusts it.
|
||||
|
||||
Line-count tolerance: INDEX.md prints what a row costs to read, so a reader can
|
||||
see the price before paying it. That number is checked against the real files
|
||||
within 3 percent or 10 lines, whichever is larger. That absorbs a reflowed
|
||||
paragraph and nothing else: the tolerance used to be 10 percent or 25 lines,
|
||||
which let a 52-line row claim 77 and let whole sections drift after an edit
|
||||
pass. If a row is out by more than a reflow, the number is simply wrong and
|
||||
recomputing it is one command.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
DOCS = REPO / 'docs'
|
||||
INDEX = DOCS / 'INDEX.md'
|
||||
START_HERE = DOCS / 'START-HERE.md'
|
||||
|
||||
# The two routers. Everything else in docs/ is a destination; these two are how
|
||||
# a reader arrives, so they are exempt from "must sit in a task row" (a row
|
||||
# pointing at the index the row is in would be noise) and their links are held
|
||||
# to a stricter standard than anyone else's.
|
||||
ROUTERS = (INDEX, START_HERE)
|
||||
|
||||
# Same skip rule as tests/test_docs_publishable.py: docs/ prose is stripped from
|
||||
# the published mirror and lives in the wiki, so a checkout with no docs/*.md
|
||||
# has nothing to route and this module is inapplicable. docs/openapi.json is
|
||||
# tracked, so keying off the DIRECTORY would make this run on the mirror.
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not any(DOCS.glob('*.md')),
|
||||
reason='no docs/ prose in this checkout - it is published to the wiki instead')
|
||||
|
||||
PURPOSES = {'tutorial', 'howto', 'reference', 'explanation'}
|
||||
AUDIENCES = {'operator', 'adopter', 'developer', 'integrator'}
|
||||
STATUSES = {'current', 'legacy', 'historical'}
|
||||
FIELDS = ('purpose', 'audience', 'status', 'summary', 'loadwith')
|
||||
ARCHIVED = {'legacy', 'historical'}
|
||||
|
||||
SUMMARY_LIMIT = 120
|
||||
|
||||
# The block must be the FIRST thing in the file, before the H1. A GitHub wiki
|
||||
# renders YAML front matter as visible junk, which is why this is an HTML
|
||||
# comment, and a comment further down the page is one a reader trips over.
|
||||
DOCMETA = re.compile(r'\A<!-- docmeta\n(.*?)\n-->\n', re.S)
|
||||
LINK = re.compile(r'\[[^\]]*\]\(([^)#]+)(?:#[^)]*)?\)')
|
||||
ARCHIVE_HEADING = re.compile(r'^##\s+Archive\b', re.M)
|
||||
ARCHIVE_TOTAL = re.compile(r'they are ([\d,]+) lines')
|
||||
ALL_LINKS = re.compile(r'\[[^\]]*\]\(([^)]+)\)')
|
||||
|
||||
|
||||
def pages():
|
||||
"""Every authored page in docs/, including adr/ and proposals/."""
|
||||
return sorted(p for p in DOCS.rglob('*.md'))
|
||||
|
||||
|
||||
def rel(path):
|
||||
return path.relative_to(DOCS).as_posix()
|
||||
|
||||
|
||||
def line_count(path):
|
||||
return len(path.read_text(errors='replace').splitlines())
|
||||
|
||||
|
||||
def parse_docmeta(path):
|
||||
"""Return the docmeta fields, or None when the block is missing."""
|
||||
match = DOCMETA.match(path.read_text(errors='replace'))
|
||||
if not match:
|
||||
return None
|
||||
fields = {}
|
||||
for line in match.group(1).splitlines():
|
||||
key, sep, value = line.partition(':')
|
||||
if not sep:
|
||||
return None
|
||||
fields[key.strip()] = value.strip()
|
||||
return fields
|
||||
|
||||
|
||||
def value(fields, key):
|
||||
"""A field with the template's trailing '# comment' hint removed."""
|
||||
return re.sub(r'\s+#.*$', '', fields.get(key, '')).strip()
|
||||
|
||||
|
||||
def status_of(path):
|
||||
fields = parse_docmeta(path)
|
||||
return value(fields, 'status') if fields else None
|
||||
|
||||
|
||||
def links_in(text):
|
||||
"""Every in-repo .md target a page links to, as written."""
|
||||
return [t for t in LINK.findall(text) if t.endswith('.md') and '://' not in t]
|
||||
|
||||
|
||||
def all_targets_in(text):
|
||||
"""Every relative link target on a page, whatever its extension.
|
||||
|
||||
links_in() only sees .md, which is right for routing questions and wrong
|
||||
for "does this file exist": the index links llms.txt, and a router that
|
||||
points at a file nobody kept is the failure this catches.
|
||||
"""
|
||||
out = []
|
||||
for target in ALL_LINKS.findall(text):
|
||||
target = target.split()[0] if target.split() else target
|
||||
if '://' in target or target.startswith(('mailto:', '#')):
|
||||
continue
|
||||
out.append(target.split('#')[0])
|
||||
return [t for t in out if t]
|
||||
|
||||
|
||||
def resolve(page, target):
|
||||
"""A link target on `page`, as a path under docs/, or None if it escapes."""
|
||||
candidate = (page.parent / target).resolve()
|
||||
try:
|
||||
candidate.relative_to(DOCS.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
def index_sections():
|
||||
"""INDEX.md split into (task side, archive side) at the Archive heading."""
|
||||
text = INDEX.read_text()
|
||||
match = ARCHIVE_HEADING.search(text)
|
||||
assert match, 'docs/INDEX.md must carry a "## Archive" section heading'
|
||||
return text[:match.start()], text[match.start():]
|
||||
|
||||
|
||||
def table_rows(text):
|
||||
"""(link targets, stated line total) for every table row that states one."""
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith('|') or set(line) <= set('|- '):
|
||||
continue
|
||||
cells = [c.strip() for c in line.strip('|').split('|')]
|
||||
if len(cells) < 2 or not cells[-1].isdigit():
|
||||
continue
|
||||
targets = links_in(line)
|
||||
if targets:
|
||||
rows.append((targets, int(cells[-1])))
|
||||
return rows
|
||||
|
||||
|
||||
def tolerance(real):
|
||||
"""How far a stated line count may sit from the real one. See the docstring."""
|
||||
return max(10, real * 0.03)
|
||||
|
||||
|
||||
def test_there_is_something_to_check():
|
||||
"""A path change that matched nothing would make this whole file vacuous."""
|
||||
assert len(pages()) > 20, 'docs/ prose disappeared, or the glob stopped matching'
|
||||
assert INDEX.is_file(), 'docs/INDEX.md is missing - there is no router'
|
||||
assert len(table_rows(index_sections()[0])) > 20, 'docs/INDEX.md routes almost nothing'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('page', pages(), ids=rel)
|
||||
def test_every_page_carries_a_docmeta_block(page):
|
||||
fields = parse_docmeta(page)
|
||||
assert fields is not None, (
|
||||
'%s has no docmeta block. It must be the first thing in the file, '
|
||||
'before the H1: an HTML comment opening "<!-- docmeta" and closing '
|
||||
'with "-->", one "key: value" per line.' % rel(page))
|
||||
missing = [f for f in FIELDS if f not in fields]
|
||||
assert not missing, '%s docmeta is missing: %s' % (rel(page), ', '.join(missing))
|
||||
unknown = [f for f in fields if f not in FIELDS]
|
||||
assert not unknown, '%s docmeta has unknown fields: %s' % (rel(page), ', '.join(unknown))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('page', pages(), ids=rel)
|
||||
def test_docmeta_uses_the_allowed_vocabularies(page):
|
||||
fields = parse_docmeta(page)
|
||||
if fields is None:
|
||||
pytest.skip('no docmeta - reported by test_every_page_carries_a_docmeta_block')
|
||||
|
||||
purpose = value(fields, 'purpose')
|
||||
assert purpose in PURPOSES, (
|
||||
'%s: purpose "%s" is not one of %s. A page that is two of these is a '
|
||||
'page that should be split.' % (rel(page), purpose, sorted(PURPOSES)))
|
||||
|
||||
audience = [a.strip() for a in value(fields, 'audience').split(',') if a.strip()]
|
||||
assert audience, '%s: audience is empty' % rel(page)
|
||||
bad = [a for a in audience if a not in AUDIENCES]
|
||||
assert not bad, '%s: audience %s not in %s' % (rel(page), bad, sorted(AUDIENCES))
|
||||
|
||||
status = value(fields, 'status')
|
||||
assert status in STATUSES, (
|
||||
'%s: status "%s" is not one of %s' % (rel(page), status, sorted(STATUSES)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('page', pages(), ids=rel)
|
||||
def test_summary_answers_is_this_the_page_i_need(page):
|
||||
fields = parse_docmeta(page)
|
||||
if fields is None:
|
||||
pytest.skip('no docmeta - reported by test_every_page_carries_a_docmeta_block')
|
||||
summary = fields.get('summary', '').strip()
|
||||
assert summary, '%s: summary is empty. It is what a router shows INSTEAD of the page.' % rel(page)
|
||||
assert len(summary) <= SUMMARY_LIMIT, (
|
||||
'%s: summary is %d chars, limit is %d. Say what question the page '
|
||||
'answers, not what it contains.' % (rel(page), len(summary), SUMMARY_LIMIT))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('page', pages(), ids=rel)
|
||||
def test_loadwith_targets_exist(page):
|
||||
"""loadwith names the pages this one is not complete without."""
|
||||
fields = parse_docmeta(page)
|
||||
if fields is None:
|
||||
pytest.skip('no docmeta - reported by test_every_page_carries_a_docmeta_block')
|
||||
raw = value(fields, 'loadwith')
|
||||
if not raw or raw.lower() == 'none':
|
||||
return
|
||||
for name in [n.strip() for n in raw.split(',') if n.strip()]:
|
||||
target = resolve(page, name) or (DOCS / name)
|
||||
if not target.is_file():
|
||||
target = DOCS / name
|
||||
assert target.is_file(), (
|
||||
'%s: loadwith names "%s", which does not exist' % (rel(page), name))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'page', [p for p in pages() if p != INDEX], ids=lambda p: rel(p))
|
||||
def test_every_page_is_routed_or_archived(page):
|
||||
"""A page nothing routes to is a page nobody reads and nobody maintains."""
|
||||
text = INDEX.read_text()
|
||||
targets = {resolve(INDEX, t) for t in links_in(text)}
|
||||
assert page in targets, (
|
||||
'%s is in docs/ but docs/INDEX.md does not name it. Add a task row for '
|
||||
'it, or - if it is superseded or only a record - mark it legacy or '
|
||||
'historical in its docmeta and list it in the Archive section.' % rel(page))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('router', ROUTERS, ids=lambda p: rel(p))
|
||||
def test_a_router_never_points_at_a_file_that_is_not_there(router):
|
||||
"""INDEX.md and START-HERE.md are the two pages everyone is sent to first.
|
||||
|
||||
A dead link here is worse than a missing row: a missing row makes a reader
|
||||
look further, a dead link makes them think the answer is gone. Pages get
|
||||
merged, split and renamed, and both routers name files by hand, so this is
|
||||
the check that catches it the same day.
|
||||
|
||||
Every relative target counts, not just .md - the index links llms.txt too -
|
||||
and a target outside docs/ is dead by definition once these pages are
|
||||
published to the wiki, which carries docs/ and nothing above it.
|
||||
"""
|
||||
broken = []
|
||||
for target in sorted(set(all_targets_in(router.read_text()))):
|
||||
page = resolve(router, target)
|
||||
if page is None:
|
||||
broken.append('%s (outside docs/)' % target)
|
||||
elif not page.is_file():
|
||||
broken.append(target)
|
||||
assert not broken, (
|
||||
'docs/%s links to files that do not exist: %s'
|
||||
% (rel(router), ', '.join(broken)))
|
||||
|
||||
|
||||
def test_every_current_page_is_reachable_from_a_task_row():
|
||||
"""Named in the index is not the same as routed by it.
|
||||
|
||||
Nothing stops a page being mentioned in a paragraph, or slipped into the
|
||||
Archive section's prose, and passing "INDEX.md names it". A reader works
|
||||
the rows: they find the row that matches the job and read what it lists. A
|
||||
current page that is in no row is a page that gets written, gets stale and
|
||||
never gets opened - and the cost of keeping it is paid by everyone who
|
||||
greps the corpus.
|
||||
|
||||
The routers themselves are exempt: they are where a reader starts.
|
||||
"""
|
||||
tasks, _ = index_sections()
|
||||
routed = set()
|
||||
for targets, _lines in table_rows(tasks):
|
||||
for target in targets:
|
||||
page = resolve(INDEX, target)
|
||||
if page:
|
||||
routed.add(page)
|
||||
|
||||
stranded = sorted(
|
||||
rel(p) for p in pages()
|
||||
if status_of(p) == 'current' and p not in routed and p not in ROUTERS)
|
||||
assert not stranded, (
|
||||
'these pages are current guidance but no task row in docs/INDEX.md '
|
||||
'sends anyone to them: %s. Give each one a row saying what task it '
|
||||
'answers, or - if it is superseded or only a record - mark it legacy '
|
||||
'or historical and move it to the Archive section.' % ', '.join(stranded))
|
||||
|
||||
|
||||
def test_task_rows_never_route_to_an_archived_page():
|
||||
"""Historical and legacy pages belong in the Archive section, nowhere else."""
|
||||
tasks, _ = index_sections()
|
||||
offenders = []
|
||||
for target in sorted(set(links_in(tasks))):
|
||||
page = resolve(INDEX, target)
|
||||
if page and page.is_file() and status_of(page) in ARCHIVED:
|
||||
offenders.append('%s (%s)' % (target, status_of(page)))
|
||||
assert not offenders, (
|
||||
'docs/INDEX.md routes a task to a page that is not current guidance: %s'
|
||||
% ', '.join(offenders))
|
||||
|
||||
|
||||
def test_the_archive_section_holds_nothing_current():
|
||||
_, archive = index_sections()
|
||||
offenders = []
|
||||
for target in sorted(set(links_in(archive))):
|
||||
page = resolve(INDEX, target)
|
||||
if page and page.is_file() and status_of(page) == 'current':
|
||||
offenders.append(target)
|
||||
assert not offenders, (
|
||||
'docs/INDEX.md files these current pages under Archive, where readers '
|
||||
'are told not to follow them: %s' % ', '.join(offenders))
|
||||
|
||||
|
||||
def test_no_current_page_hides_behind_an_archived_one():
|
||||
"""Walk out from INDEX.md without passing through legacy or historical pages.
|
||||
|
||||
A current page that can only be reached through a superseded one is a page
|
||||
a careful reader will never open, because they were told to skip the page
|
||||
that links it.
|
||||
"""
|
||||
seen = set()
|
||||
queue = [INDEX]
|
||||
while queue:
|
||||
page = queue.pop()
|
||||
if page in seen:
|
||||
continue
|
||||
seen.add(page)
|
||||
if status_of(page) in ARCHIVED:
|
||||
continue # a dead end on purpose: nothing may depend on it
|
||||
for target in links_in(page.read_text(errors='replace')):
|
||||
nxt = resolve(page, target)
|
||||
if nxt and nxt.is_file():
|
||||
queue.append(nxt)
|
||||
|
||||
stranded = sorted(
|
||||
rel(p) for p in pages() if status_of(p) == 'current' and p not in seen)
|
||||
assert not stranded, (
|
||||
'these current pages are only reachable through an archived page: %s'
|
||||
% ', '.join(stranded))
|
||||
|
||||
|
||||
def test_index_line_totals_are_honest():
|
||||
"""The cost column is the point of the index. A wrong one is worse than none."""
|
||||
tasks, archive = index_sections()
|
||||
offenders = []
|
||||
for targets, stated in table_rows(tasks) + table_rows(archive):
|
||||
real = 0
|
||||
for target in targets:
|
||||
page = resolve(INDEX, target)
|
||||
if page and page.is_file():
|
||||
real += line_count(page)
|
||||
if abs(real - stated) > tolerance(real):
|
||||
offenders.append('%s: says %d lines, is %d' % ('+'.join(targets), stated, real))
|
||||
assert not offenders, (
|
||||
'docs/INDEX.md misstates what a row costs to read:\n %s' % '\n '.join(offenders))
|
||||
|
||||
|
||||
def test_the_archive_total_is_honest():
|
||||
_, archive = index_sections()
|
||||
match = ARCHIVE_TOTAL.search(archive)
|
||||
assert match, 'the Archive section should say how many lines it adds up to'
|
||||
stated = int(match.group(1).replace(',', ''))
|
||||
real = sum(count for _, count in table_rows(archive))
|
||||
assert abs(real - stated) <= tolerance(real), (
|
||||
'the Archive section claims %d lines; its rows add up to %d' % (stated, real))
|
||||
@@ -94,7 +94,23 @@ def documentation_files():
|
||||
|
||||
def test_there_are_docs_to_check():
|
||||
"""A path change that silently matched nothing would make this suite pass
|
||||
while checking absolutely nothing."""
|
||||
while checking absolutely nothing.
|
||||
|
||||
FLAG FOR DOC AUTHORS: if this fails with a SMALL number (3, not 0), you have
|
||||
almost certainly just committed a docs/*.md file. Don't lower the threshold
|
||||
to make it pass - that is the exact failure this assertion exists to catch,
|
||||
and it would silently disable the whole scrub suite.
|
||||
|
||||
docs/*.md prose is wiki-published and MUST stay untracked. The gates decide
|
||||
'is this a published checkout?' by the ABSENCE of docs/*.md, so one tracked
|
||||
prose file makes CI look internal while carrying no prose, and takes down
|
||||
backend AND gates together. docs/DEV-BUILD-TEST.md and docs/PLUGIN-HOOKS.md
|
||||
did exactly this and held the branch red for eight runs; both were untracked
|
||||
(kept on disk) and .gitignore now ignores docs/*.md wholesale.
|
||||
|
||||
Authoring a doc is still fine - write it in docs/, it just stays local and
|
||||
goes to the wiki. docs/notes/ and docs/openapi.json are unaffected.
|
||||
"""
|
||||
assert len(documentation_files()) > 20
|
||||
|
||||
|
||||
|
||||
@@ -121,7 +121,14 @@ def test_a_stated_plugin_count_matches_the_tree():
|
||||
|
||||
def test_the_map_itself_is_current():
|
||||
"""The pointer target has to be right, or every document pointing at it is
|
||||
wrong at one remove."""
|
||||
wrong at one remove.
|
||||
|
||||
FLAG FOR DOC AUTHORS: if this fails saying PROJECT-MAP.md is missing, the
|
||||
likely cause is not a missing map - it is that you committed a docs/*.md
|
||||
file, which stopped this module from skipping as a published checkout. The
|
||||
map is generated and correctly untracked. See the note in
|
||||
tests/test_docs_publishable.py::test_there_are_docs_to_check.
|
||||
"""
|
||||
mapfile = DOCS / 'PROJECT-MAP.md'
|
||||
assert mapfile.is_file(), 'the PROJECT-MAP wiki page is missing; run scripts/gen_project_map.py'
|
||||
text = mapfile.read_text()
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""The installer wizard's pre-ticked feature list must match the plugin manifests.
|
||||
|
||||
The wizard cannot read manifest.json - Inno's Pascal Script has no JSON parser -
|
||||
so the default set is a hardcoded list in ShopDBFlask.iss. It drifted: two plugins
|
||||
that ship "default_enabled": false were pre-ticked, so every site taking the
|
||||
defaults installed and enabled them against their own manifests.
|
||||
|
||||
A hardcoded list is fine; a hardcoded list nobody checks is not.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
ISS = REPO / 'deploy' / 'windows' / 'installer' / 'ShopDBFlask.iss'
|
||||
PLUGINS = REPO / 'plugins'
|
||||
|
||||
pytestmark = pytest.mark.skipif(not ISS.exists(), reason='installer not in this tree')
|
||||
|
||||
|
||||
def manifests_defaulting_off():
|
||||
off = set()
|
||||
for manifest in PLUGINS.glob('*/manifest.json'):
|
||||
try:
|
||||
data = json.loads(manifest.read_text())
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if data.get('default_enabled') is False:
|
||||
off.add(manifest.parent.name)
|
||||
return off
|
||||
|
||||
|
||||
def wizard_excludes():
|
||||
"""The names PluginDefault returns False for."""
|
||||
body = re.search(r'function PluginDefault.*?\nend;', ISS.read_text(), re.S)
|
||||
assert body, 'PluginDefault not found in ShopDBFlask.iss'
|
||||
return set(re.findall(r"Name <> '([a-z_]+)'", body.group(0)))
|
||||
|
||||
|
||||
def test_wizard_defaults_match_the_manifests():
|
||||
off = manifests_defaulting_off()
|
||||
assert off, 'no plugin declares default_enabled false - has the field moved?'
|
||||
assert wizard_excludes() == off, (
|
||||
'ShopDBFlask.iss PluginDefault disagrees with the manifests.\n'
|
||||
' manifests default_enabled=false: %s\n'
|
||||
' wizard leaves unticked: %s' % (sorted(off), sorted(wizard_excludes())))
|
||||
|
||||
|
||||
def test_every_wizard_exclusion_is_a_real_plugin():
|
||||
"""A typo in the .iss list silently pre-ticks the plugin it meant to exclude."""
|
||||
for name in wizard_excludes():
|
||||
assert (PLUGINS / name / 'manifest.json').exists(), (
|
||||
'%s is excluded by the wizard but has no manifest' % name)
|
||||
|
||||
|
||||
# The preflight blocks the wizard on any FAIL. That is correct for something the
|
||||
# operator must go and fix, and wrong for anything the installer carries in its
|
||||
# own bundle - there, blocking stops the wizard over something it was about to do
|
||||
# itself, with no way forward.
|
||||
BUNDLE_SUPPLIED = [
|
||||
'HttpPlatformHandler', # httpplatformhandler\*.msi, installed by stage 4
|
||||
'URL Rewrite', # urlrewrite\*.msi, installed by stage 4
|
||||
]
|
||||
|
||||
PREFLIGHT = REPO / 'deploy' / 'windows' / 'installer' / 'shopdb-preflight.ps1'
|
||||
|
||||
|
||||
def test_nothing_the_bundle_supplies_is_a_blocker():
|
||||
"""Regression: HttpPlatformHandler was a FAIL, so a server without it could
|
||||
not get past the preflight page - to install the very thing that was
|
||||
missing."""
|
||||
text = PREFLIGHT.read_text(encoding='utf-8', errors='replace')
|
||||
offenders = []
|
||||
for line in text.splitlines():
|
||||
if "'FAIL'" not in line:
|
||||
continue
|
||||
for component in BUNDLE_SUPPLIED:
|
||||
if component.lower() in line.lower():
|
||||
offenders.append(line.strip())
|
||||
assert not offenders, (
|
||||
'the installer supplies these, so they must not block the wizard:\n %s'
|
||||
% '\n '.join(offenders))
|
||||
|
||||
|
||||
def test_installer_controlled_paths_have_no_spaces():
|
||||
"""msiexec and bootstrapper command lines split on spaces.
|
||||
|
||||
The installer passes these as PROPERTY=value / TargetDir=value on a command
|
||||
line. A value containing a space has to be quoted as PROPERTY="C:\\..." -
|
||||
quoting the whole token instead makes the tool reject the line and print its
|
||||
usage dialog, which is what 'the bundled MySQL database could not be
|
||||
installed' looked like from the outside. The Python target was already
|
||||
space-free for this exact reason; MySQL was not.
|
||||
"""
|
||||
text = PREFLIGHT.parent.joinpath('shopdb-install.ps1').read_text(
|
||||
encoding='utf-8', errors='replace')
|
||||
offenders = []
|
||||
for line in text.splitlines():
|
||||
match = re.match(r"\s*\[string\]\s*\$(Mysql\w*|PyTarget)\s*=\s*'([^']+)'", line)
|
||||
if match and ' ' in match.group(2):
|
||||
offenders.append('%s = %s' % (match.group(1), match.group(2)))
|
||||
assert not offenders, (
|
||||
'these are passed on a command line and must not contain spaces:\n %s'
|
||||
% '\n '.join(offenders))
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Guards on the installer's durable install record.
|
||||
|
||||
The record replaced a set of guesses. "Is this a re-run of MY install?" used to
|
||||
be answered from whatever the machine looked like - a MySQL service exists, the
|
||||
database has tables, the site exists - and none of those say who created them.
|
||||
A retry after a failed first install was therefore taken for an upgrade of a
|
||||
working system, which dead-ended the retry the wizard invites the operator to
|
||||
run.
|
||||
|
||||
Getting it wrong is asymmetric. Answering "still first provisioning" for a
|
||||
server holding real data would let `prune-schema --force` drop live tables. The
|
||||
behavioural checks live in the PowerShell harness next to the installer, run
|
||||
here when pwsh is available; the text assertions below hold everywhere, because
|
||||
CI does not necessarily have pwsh and these are the invariants worth failing a
|
||||
build over.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
INSTALLER = REPO / 'deploy' / 'windows' / 'installer' / 'shopdb-install.ps1'
|
||||
HARNESS = REPO / 'deploy' / 'windows' / 'installer' / 'tests' / 'test-install-state.ps1'
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not INSTALLER.is_file(),
|
||||
reason='installer not in this checkout')
|
||||
|
||||
|
||||
def installer_text():
|
||||
return INSTALLER.read_text(encoding='utf-8', errors='replace')
|
||||
|
||||
|
||||
def test_prune_force_is_gated_on_first_provisioning():
|
||||
"""--force drops tables that contain rows.
|
||||
|
||||
It is correct on a first install, where core migrations seed a few plugin
|
||||
reference tables, and destructive on an upgrade, where those rows are the
|
||||
site's data. The gate must therefore test BOTH "database was empty" and the
|
||||
install record, never the database alone.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert "'prune-schema','--yes','--force'" in text, 'the forcing call moved or was renamed'
|
||||
guard = "if ((-not $script:DbWasEmpty) -and (-not $firstRun)) {"
|
||||
assert guard in text, (
|
||||
'the prune gate no longer consults the install record. Forcing on the '
|
||||
'strength of an empty database alone is what made every retry of a '
|
||||
'failed first install fail at stage 3.')
|
||||
|
||||
|
||||
def test_first_provisioning_comes_from_the_record_not_the_database():
|
||||
text = installer_text()
|
||||
assert 'function Test-FirstProvisioning' in text
|
||||
assert '$firstRun = Test-FirstProvisioning' in text, (
|
||||
'stage 3 must ask the record, not infer from table counts')
|
||||
|
||||
|
||||
def test_missing_record_takes_the_safe_side():
|
||||
"""An install predating the record has a version stamp and probably data.
|
||||
|
||||
Treating that as a first run would arm prune --force against it.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert "'predates-this-record'" in text, (
|
||||
'the fallback for an unrecorded but stamped install is gone; without it '
|
||||
'an established server can be classified as first provisioning')
|
||||
|
||||
|
||||
def test_created_items_returns_an_array_when_empty():
|
||||
"""A zero-length array returned from a PowerShell function unrolls to $null.
|
||||
|
||||
$null.Count is then a terminating error under Set-StrictMode -Version 2.0 -
|
||||
the same fault that made bundle verification fail on every install. The
|
||||
leading comma is what prevents it.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert 'return ,@()' in text, 'Get-CreatedItems lost its comma protection'
|
||||
assert 'return ,@(Get-StateValue $created $Kind' in text, (
|
||||
'the populated return path lost its comma protection')
|
||||
|
||||
|
||||
def test_record_is_written_before_any_stage_runs():
|
||||
"""Written at the START of provisioning, not the end.
|
||||
|
||||
A run that dies halfway is exactly the run whose retry needs the record.
|
||||
"""
|
||||
text = installer_text()
|
||||
assert "if ($Stage -ne 'uninstall') { Initialize-InstallState }" in text
|
||||
start = text.index('Initialize-InstallState }')
|
||||
switch = text.index('switch ($Stage)', start - 400)
|
||||
assert start < switch, 'the record must be initialised before the stage switch'
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which('pwsh') is None, reason='pwsh not installed')
|
||||
def test_install_state_behaviour():
|
||||
"""Run the PowerShell harness: retries, upgrades, corrupt and foreign records."""
|
||||
assert HARNESS.is_file(), 'the PowerShell harness is missing'
|
||||
result = subprocess.run(
|
||||
['pwsh', '-NoProfile', '-File', str(HARNESS)],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
assert result.returncode == 0, (
|
||||
'install-record harness failed:\n' + result.stdout + result.stderr)
|
||||
assert 'all checks passed' in result.stdout
|
||||
295
tests/test_naming_gate_migrations.py
Normal file
295
tests/test_naming_gate_migrations.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""Holes in `scripts/check-naming-and-style.sh`, each held open by a test.
|
||||
|
||||
The naming gate must read the column names migrations CREATE, not just models.
|
||||
|
||||
`scripts/check-naming-and-style.sh` checked `db.Column` attribute names on
|
||||
models and nothing else, while `migrations/versions/` is filtered out of the
|
||||
file list every other check in that script uses. A column is born in a
|
||||
migration, so from the day the gate was written no created column name was ever
|
||||
looked at: a model attribute reading fine says nothing about what the database
|
||||
actually got.
|
||||
|
||||
Two tests, and the second is the one that matters. A gate that prints nothing
|
||||
looks identical whether it is clean or broken, and this one was silently
|
||||
passing everything for its whole life, so the test plants a violation and
|
||||
requires the gate to fail on it. Without that, a future edit that breaks the
|
||||
regex would leave every test here green.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
GATE = REPO_ROOT / 'scripts' / 'check-naming-and-style.sh'
|
||||
|
||||
# Same shapes the gate matches: the name literal of a created column, positional
|
||||
# or keyword, on the sa.Column( line or on the line after it, plus the target of
|
||||
# a rename.
|
||||
COLUMN_LITERAL = re.compile(r"""sa\.Column\(\s*['"]([^'"]+)['"]""", re.S)
|
||||
COLUMN_KEYWORD = re.compile(
|
||||
r"""sa\.Column\(\s*name\s*=\s*['"]([^'"]+)['"]""", re.S)
|
||||
RENAME_LITERAL = re.compile(r"""new_column_name\s*=\s*['"]([^'"]+)['"]""")
|
||||
|
||||
# THE RULE, not a proxy for it. The first version of this file asked whether the
|
||||
# name contained an underscore, which is the same mistake the gate's own regex
|
||||
# made: 'Monitor_Name' has one but was missed for its leading capital, and a
|
||||
# CamelCase name with no underscore at all is just as wrong and has nothing to
|
||||
# find. Lowercase concatenated means lowercase concatenated.
|
||||
LOWERCASE_CONCATENATED = re.compile(r'[a-z][a-z0-9]*\Z')
|
||||
|
||||
PLANTED = '''"""Planted by the test suite. Never applied."""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = 'plantedprobe'
|
||||
down_revision = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('widgets', sa.Column('planted_column', sa.Integer(),
|
||||
nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('widgets', 'planted_column')
|
||||
'''
|
||||
|
||||
# ASSEMBLED FROM FRAGMENTS, like the gate's own patterns and
|
||||
# tests/test_docs_publishable.py: written literally, a test for the site-literal
|
||||
# rule is itself a site literal, and both the gate and the publication scrub
|
||||
# would find it here.
|
||||
PLANTED_SITE_HOST = 'tsg' 'wp00525' '.geaero' 'space.net'
|
||||
PLANTED_AGENT = (
|
||||
'// Planted by the test suite.\n'
|
||||
'internal static class Probe\n'
|
||||
'{\n'
|
||||
' public const string DefaultBaseUrl = "https://'
|
||||
+ PLANTED_SITE_HOST + '/shopdb";\n'
|
||||
'}\n'
|
||||
)
|
||||
|
||||
|
||||
def _migration_files():
|
||||
found = list((REPO_ROOT / 'migrations' / 'versions').glob('*.py'))
|
||||
found += list(REPO_ROOT.glob('plugins/*/migrations/versions/*.py'))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def test_there_are_migrations_to_check():
|
||||
"""Guard the guard: a glob that matches nothing passes everything below."""
|
||||
assert _migration_files(), 'no migrations found - has the layout moved?'
|
||||
|
||||
|
||||
def test_no_migration_creates_a_badly_named_column():
|
||||
offenders = []
|
||||
for path in _migration_files():
|
||||
text = path.read_text(encoding='utf-8')
|
||||
for pattern in (COLUMN_LITERAL, COLUMN_KEYWORD, RENAME_LITERAL):
|
||||
for name in pattern.findall(text):
|
||||
if not LOWERCASE_CONCATENATED.match(name):
|
||||
offenders.append('{0}: {1}'.format(
|
||||
path.relative_to(REPO_ROOT), name))
|
||||
assert not offenders, (
|
||||
'column names are lowercase concatenated (CONTRIBUTING.md):\n '
|
||||
+ '\n '.join(offenders))
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
|
||||
def test_the_gate_fails_on_a_planted_migration_column(tmp_path):
|
||||
"""Plant one, and require the gate to say so.
|
||||
|
||||
Run against a throwaway tree rather than the repository, so the planted
|
||||
file cannot be left behind by a failing assert. tmp_path is outside any
|
||||
checkout, so the script's git file list is unavailable and it takes its
|
||||
own find fallback - which is the branch a developer without git metadata
|
||||
gets, and so is worth exercising.
|
||||
"""
|
||||
versions = tmp_path / 'migrations' / 'versions'
|
||||
versions.mkdir(parents=True)
|
||||
gate = tmp_path / 'scripts' / 'check-naming-and-style.sh'
|
||||
gate.parent.mkdir(parents=True)
|
||||
shutil.copy(GATE, gate)
|
||||
|
||||
planted = versions / 'zz01_planted.py'
|
||||
planted.write_text(PLANTED, encoding='utf-8')
|
||||
dirty = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
|
||||
capture_output=True, text=True)
|
||||
assert dirty.returncode == 1, (
|
||||
'the gate passed a snake_case migration column:\n' + dirty.stdout)
|
||||
assert 'planted_column' in dirty.stdout
|
||||
|
||||
planted.unlink()
|
||||
clean = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
|
||||
capture_output=True, text=True)
|
||||
assert clean.returncode == 0, (
|
||||
'the gate fails with nothing planted:\n' + clean.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
|
||||
def test_the_gate_fails_on_a_site_literal_in_the_agent(tmp_path):
|
||||
"""agent/ is in the ADR-015 scan, and has to stay in it.
|
||||
|
||||
The C# agent is compiled once and installed on every bay at every site. A
|
||||
site hostname, base URL or pin baked into a .cs file there ships to the next
|
||||
site inside a binary, where no reviewer reads it - and agent/ was not one of
|
||||
the directories the site-literal scan looked at.
|
||||
"""
|
||||
gate = tmp_path / 'scripts' / 'check-naming-and-style.sh'
|
||||
gate.parent.mkdir(parents=True)
|
||||
shutil.copy(GATE, gate)
|
||||
agent = tmp_path / 'agent' / 'Probe'
|
||||
agent.mkdir(parents=True)
|
||||
|
||||
planted = agent / 'Probe.cs'
|
||||
planted.write_text(PLANTED_AGENT, encoding='utf-8')
|
||||
dirty = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
|
||||
capture_output=True, text=True)
|
||||
assert dirty.returncode == 1, (
|
||||
'the gate passed a site hostname in the agent:\n' + dirty.stdout)
|
||||
assert 'Probe.cs' in dirty.stdout
|
||||
|
||||
planted.unlink()
|
||||
clean = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
|
||||
capture_output=True, text=True)
|
||||
assert clean.returncode == 0, (
|
||||
'the gate fails with nothing planted:\n' + clean.stdout)
|
||||
|
||||
|
||||
# Every shape a migration writes a column name in. The gate's first regex asked
|
||||
# for ['"][a-z0-9]*_ inside the quotes, so only the first of these was ever
|
||||
# caught: a leading capital, a keyword argument or a CamelCase name with no
|
||||
# underscore all walked past it. One case per shape, named so a failure says
|
||||
# which shape regressed.
|
||||
PLANTED_COLUMN_SHAPES = {
|
||||
'positional_snake': (
|
||||
" op.add_column('widgets', sa.Column('planted_column',\n"
|
||||
" sa.Integer()))\n", 'planted_column'),
|
||||
'positional_leading_capital': (
|
||||
" op.add_column('widgets', sa.Column('Monitor_Name',\n"
|
||||
" sa.String(50)))\n", 'Monitor_Name'),
|
||||
'positional_camelcase_no_underscore': (
|
||||
" op.add_column('widgets', sa.Column('MonitorName', sa.String(50)))\n",
|
||||
'MonitorName'),
|
||||
'keyword_name': (
|
||||
" op.add_column('widgets', sa.Column(name='kw_form',\n"
|
||||
" type_=sa.String()))\n", 'kw_form'),
|
||||
'wrapped_after_open_paren': (
|
||||
" op.add_column('widgets', sa.Column(\n"
|
||||
" 'wrapped_name', sa.Integer()))\n", 'wrapped_name'),
|
||||
'alter_column_rename': (
|
||||
" op.alter_column('widgets', 'oldname',\n"
|
||||
" new_column_name='New_Name')\n", 'New_Name'),
|
||||
'create_table_second_column': (
|
||||
" op.create_table('t2', sa.Column('goodone', sa.Integer()),\n"
|
||||
" sa.Column('Bad_Two', sa.Integer()))\n", 'Bad_Two'),
|
||||
'batch_alter_table_add': (
|
||||
" with op.batch_alter_table('widgets') as batch_op:\n"
|
||||
" batch_op.add_column(sa.Column('batchCol', sa.Integer()))\n",
|
||||
'batchCol'),
|
||||
'batch_alter_table_rename': (
|
||||
" with op.batch_alter_table('widgets') as batch_op:\n"
|
||||
" batch_op.alter_column('a', new_column_name='b_c')\n", 'b_c'),
|
||||
}
|
||||
|
||||
PLANTED_CLEAN_BODY = (
|
||||
" op.add_column('widgets', sa.Column('goodname', sa.Integer()))\n"
|
||||
" op.add_column('widgets', sa.Column(name='alsogood', type_=sa.String()))\n"
|
||||
" op.alter_column('widgets', 'oldname', new_column_name='newname')\n"
|
||||
# drop_column names a column that already exists, so a bad name here is
|
||||
# deliberately NOT a finding: it was wrong where it was created, and
|
||||
# reporting it again makes one rename look like two.
|
||||
" op.drop_column('widgets', 'legacy_old')\n"
|
||||
)
|
||||
|
||||
|
||||
def _migration_module(body):
|
||||
return ('"""Planted by the test suite. Never applied."""\n'
|
||||
'import sqlalchemy as sa\n'
|
||||
'from alembic import op\n\n'
|
||||
"revision = 'plantedprobe'\n"
|
||||
'down_revision = None\n\n\n'
|
||||
'def upgrade():\n' + body + '\n\n'
|
||||
'def downgrade():\n'
|
||||
' pass\n')
|
||||
|
||||
|
||||
def _gate_tree(tmp_path):
|
||||
gate = tmp_path / 'scripts' / 'check-naming-and-style.sh'
|
||||
gate.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(GATE, gate)
|
||||
return gate
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
|
||||
@pytest.mark.parametrize('shape', sorted(PLANTED_COLUMN_SHAPES))
|
||||
def test_the_gate_catches_every_column_shape(tmp_path, shape):
|
||||
"""Plant one shape at a time and require the gate to name it.
|
||||
|
||||
Parametrised rather than one file holding all nine, so a regression says
|
||||
WHICH shape stopped being seen. A single file would fail on the first one
|
||||
and hide the rest.
|
||||
"""
|
||||
body, expected = PLANTED_COLUMN_SHAPES[shape]
|
||||
versions = tmp_path / 'migrations' / 'versions'
|
||||
versions.mkdir(parents=True)
|
||||
gate = _gate_tree(tmp_path)
|
||||
|
||||
planted = versions / 'zz01_planted.py'
|
||||
planted.write_text(_migration_module(body), encoding='utf-8')
|
||||
dirty = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
|
||||
capture_output=True, text=True)
|
||||
assert dirty.returncode == 1, (
|
||||
'the gate passed a {0} column:\n{1}'.format(shape, dirty.stdout))
|
||||
assert expected in dirty.stdout, (
|
||||
'the gate failed but never named {0}:\n{1}'.format(
|
||||
expected, dirty.stdout))
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
|
||||
def test_the_gate_passes_well_named_columns(tmp_path):
|
||||
"""The other half: a gate that fails on everything is no better.
|
||||
|
||||
Includes a drop_column with a bad name, which is out of scope on purpose.
|
||||
"""
|
||||
versions = tmp_path / 'migrations' / 'versions'
|
||||
versions.mkdir(parents=True)
|
||||
gate = _gate_tree(tmp_path)
|
||||
(versions / 'zz01_clean.py').write_text(
|
||||
_migration_module(PLANTED_CLEAN_BODY), encoding='utf-8')
|
||||
clean = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
|
||||
capture_output=True, text=True)
|
||||
assert clean.returncode == 0, (
|
||||
'the gate failed on lowercase concatenated names:\n' + clean.stdout)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which('bash') is None, reason='needs bash')
|
||||
def test_the_site_literal_scan_honours_its_include_list(tmp_path):
|
||||
"""The ADR-015 scan must read the file types it declares, and no others.
|
||||
|
||||
The scan carried --exclude=README.md AHEAD of its --include list, and on GNU
|
||||
grep a file-level exclude in that position cancels every include on the
|
||||
command. So the scan was reading EVERY file under agent/ and the other
|
||||
scanned trees, and --include='*.cs' was decorative - adding agent/ did not
|
||||
mean the C# agent was covered for the reason the flags claimed, and any
|
||||
later narrowing of the include list would have been silently inert.
|
||||
|
||||
The .cs half is covered by the test above. This is the other half: a file
|
||||
type the scan does not declare must not fail it, which is only true when the
|
||||
include list is actually in force. Before the fix this run came back red on
|
||||
notes.txt.
|
||||
"""
|
||||
gate = _gate_tree(tmp_path)
|
||||
agent = tmp_path / 'agent' / 'Probe'
|
||||
agent.mkdir(parents=True)
|
||||
for name in ('notes.txt', 'Probe.csproj', 'README.md'):
|
||||
(agent / name).write_text(
|
||||
'shipped to ' + PLANTED_SITE_HOST + '\n', encoding='utf-8')
|
||||
|
||||
result = subprocess.run(['bash', str(gate)], cwd=str(tmp_path),
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, (
|
||||
'the scan read a file type it does not declare:\n' + result.stdout)
|
||||
@@ -6,8 +6,8 @@ and still reports nothing.
|
||||
|
||||
That is not hypothetical. plugins/endpoints/tests/test_plugin.py arrived with a
|
||||
merge carrying 55 lines of delivery assertions and was collected ZERO times; run
|
||||
directly, five of its tests failed. plugins/printedparts/tests/test_plugin.py had
|
||||
been sitting uncollected for longer. Both are now under tests/.
|
||||
directly, five of its tests failed. A second plugin had one sitting uncollected
|
||||
for longer. Both are now under tests/.
|
||||
|
||||
The failure mode is the worst kind: the suite is green, the coverage is imaginary,
|
||||
and the authorisation on a route can be deleted without anything going red.
|
||||
@@ -24,10 +24,10 @@ COLLECTED = REPO / 'tests'
|
||||
IGNORED = ('node_modules', 'venv', '.git', 'dist', '__pycache__',
|
||||
'site-packages', '.plugins-staged', 'frontend')
|
||||
|
||||
# The Windows installer stages a whole copy of the app under here. It is build
|
||||
# output, not source, and the export excludes it - caught by this test on its
|
||||
# first run, which is the sort of thing the ignore list exists for.
|
||||
IGNORED_TREES = ('deploy/windows/installer/bundle',)
|
||||
# Whole-tree copies a build stages somewhere under the repo root. Build output,
|
||||
# not source, so a test file inside one is not a stray. Empty today; add a path
|
||||
# here rather than loosening IGNORED above, which matches on any path part.
|
||||
IGNORED_TREES = ()
|
||||
|
||||
|
||||
def test_every_test_file_lives_where_pytest_looks():
|
||||
|
||||
637
tests/test_openapi_examples.py
Normal file
637
tests/test_openapi_examples.py
Normal file
@@ -0,0 +1,637 @@
|
||||
"""The two derived halves of the published spec: query parameters and examples.
|
||||
|
||||
docs/openapi.json is served at /api/docs on production AND published to the
|
||||
wiki, and it is what the MCP server builds its tools from. Two things were
|
||||
missing from every operation in it, and both are now derived rather than
|
||||
written:
|
||||
|
||||
QUERY PARAMETERS. A Flask rule carries its path arguments and nothing else,
|
||||
so 0 of 573 operations published a query parameter - including
|
||||
a setup route where '?code=' is the only way to call it successfully.
|
||||
scripts/gen_openapi.py now reads them out of each view's AST.
|
||||
|
||||
EXAMPLES. 0 of 573 carried a machine-readable example. They are harvested
|
||||
from the test suite, which already sends real bodies and asserts real
|
||||
responses.
|
||||
|
||||
Harvesting is where this module earns its place. A harvested body is whatever a
|
||||
test happened to build, and tests are written with real-looking material -
|
||||
passwords, tokens, badge numbers, hostnames, addresses. None of that matters in
|
||||
a test file and all of it matters on a public page, so redaction runs before
|
||||
anything reaches the spec. `redact` is tested here directly, and the generated
|
||||
spec is swept for the same material, because a scrubber nobody checks is a
|
||||
scrubber that stops working quietly.
|
||||
|
||||
The other property tested here is that NOTHING IS INVENTED. A query argument
|
||||
whose name the generator cannot resolve to a literal is not published at all:
|
||||
an argument that is not really there is worse than a missing one, because a
|
||||
reader believes it, sends it and is silently ignored.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import re
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SPEC = REPO / 'docs' / 'openapi.json'
|
||||
|
||||
|
||||
def _generator():
|
||||
"""scripts/gen_openapi.py, loaded by path.
|
||||
|
||||
It is a script, not a package module. Loading it runs its imports and
|
||||
nothing else - it builds no app and reads no database until it is asked to.
|
||||
"""
|
||||
path = REPO / 'scripts' / 'gen_openapi.py'
|
||||
spec = importlib.util.spec_from_file_location('gen_openapi_examples', path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def generator():
|
||||
return _generator()
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def spec():
|
||||
if not SPEC.is_file():
|
||||
pytest.skip('no generated spec in this checkout')
|
||||
return json.loads(SPEC.read_text())
|
||||
|
||||
|
||||
def operations(spec):
|
||||
for path, item in spec['paths'].items():
|
||||
for verb, operation in item.items():
|
||||
yield path, verb, operation
|
||||
|
||||
|
||||
def examples(spec):
|
||||
"""Every published example, as (where, value)."""
|
||||
for path, verb, operation in operations(spec):
|
||||
body = operation.get('requestBody') or {}
|
||||
media = (body.get('content') or {}).get('application/json') or {}
|
||||
if 'example' in media:
|
||||
yield '%s %s request' % (verb.upper(), path), media['example']
|
||||
success = ((operation.get('responses') or {}).get('200') or {})
|
||||
media = (success.get('content') or {}).get('application/json') or {}
|
||||
if 'example' in media:
|
||||
yield '%s %s response' % (verb.upper(), path), media['example']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# redact
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_credential_is_redacted_by_its_key(generator):
|
||||
"""The key is the reliable signal, whatever the value happens to be today.
|
||||
|
||||
A fixture that leaves passwordhash empty must not teach the scrubber that
|
||||
passwordhash is safe.
|
||||
"""
|
||||
scrubbed = generator.redact({
|
||||
'password': 'hunter2',
|
||||
'passwordhash': '',
|
||||
'apikey': 'abc',
|
||||
'refresh_token': 'abc',
|
||||
'X-API-Key': 'abc',
|
||||
'signature': 'abc',
|
||||
})
|
||||
assert set(scrubbed.values()) == {'<redacted:secret>'}
|
||||
|
||||
|
||||
def test_a_person_and_a_machine_are_redacted_by_their_key(generator):
|
||||
"""Not secrets, and not ours to publish either. ADR-015 for the machine
|
||||
half: one plant's server name is meaningless to every other site reading
|
||||
these pages."""
|
||||
scrubbed = generator.redact({
|
||||
'username': 'jsmith',
|
||||
'badge': '204411',
|
||||
'checkedoutby': 'J Smith',
|
||||
'hostname': 'WJSF0123',
|
||||
'fqdn': 'wjsf0123.example.net',
|
||||
'ipaddress': '10.4.2.19',
|
||||
'servicetag': 'H3K9QP2',
|
||||
})
|
||||
assert scrubbed['username'] == '<redacted:person>'
|
||||
assert scrubbed['badge'] == '<redacted:person>'
|
||||
assert scrubbed['checkedoutby'] == '<redacted:person>'
|
||||
assert scrubbed['hostname'] == '<redacted:host>'
|
||||
assert scrubbed['fqdn'] == '<redacted:host>'
|
||||
assert scrubbed['ipaddress'] == '<redacted:host>'
|
||||
assert scrubbed['servicetag'] == '<redacted:host>'
|
||||
|
||||
|
||||
def test_a_credential_with_no_telling_key_is_redacted_by_its_value(generator):
|
||||
"""The half the key rules cannot reach: material in a free-text field, or
|
||||
in a list, where nothing names it."""
|
||||
scrubbed = generator.redact({
|
||||
'note': 'mail bob.smith@example.com when it lands',
|
||||
'detail': 'bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaaa.bbbb',
|
||||
'path': '\\\\fileserver\\deploy\\agent.msi',
|
||||
'where': 'reported from 10.11.12.13',
|
||||
'digest': 'd41d8cd98f00b204e9800998ecf8427e',
|
||||
'bag': ['carol@example.com'],
|
||||
})
|
||||
assert scrubbed['note'] == '<redacted:email>'
|
||||
assert scrubbed['detail'] == '<redacted:token>'
|
||||
assert scrubbed['path'] == '<redacted:sharepath>'
|
||||
assert scrubbed['where'] == '<redacted:address>'
|
||||
assert scrubbed['digest'] == '<redacted:digest>'
|
||||
assert scrubbed['bag'] == ['<redacted:email>']
|
||||
|
||||
|
||||
def test_ordinary_material_survives(generator):
|
||||
"""Over-redaction has a cost too: an example scrubbed to nothing shows a
|
||||
reader nothing. What is safe stays."""
|
||||
payload = {
|
||||
'assetnumber': 'A-10024',
|
||||
'model': 'Dell Latitude 5420',
|
||||
'isactive': True,
|
||||
'quantity': 12,
|
||||
'status': 'In Service',
|
||||
'url': 'http://localhost:5001/api/assets',
|
||||
}
|
||||
assert generator.redact(payload) == payload
|
||||
|
||||
|
||||
def test_a_numeric_id_is_not_a_credential(generator):
|
||||
"""'tokenid': 3 is the row number, not the token.
|
||||
|
||||
Redacting it cost every token route the one field a caller needs to address
|
||||
the row. The exemption is numeric-only, so a string session id is still
|
||||
scrubbed.
|
||||
"""
|
||||
scrubbed = generator.redact({'tokenid': 3, 'apikeyid': 7,
|
||||
'sessionid': 'a-long-opaque-string-value'})
|
||||
assert scrubbed['tokenid'] == 3
|
||||
assert scrubbed['apikeyid'] == 7
|
||||
assert scrubbed['sessionid'] == '<redacted:secret>'
|
||||
|
||||
|
||||
def test_redaction_reaches_every_level(generator):
|
||||
"""A token two objects down is as published as one at the top."""
|
||||
scrubbed = generator.redact(
|
||||
{'data': {'items': [{'inner': {'password': 'hunter2'}}]}})
|
||||
assert scrubbed['data']['items'][0]['inner']['password'] == '<redacted:secret>'
|
||||
|
||||
|
||||
def test_redaction_is_idempotent(generator):
|
||||
"""A harvest runs again over a spec that already carries markers, and the
|
||||
markers must not be re-scrubbed into something else."""
|
||||
once = generator.redact({'password': 'hunter2', 'model': 'Dell 5420'})
|
||||
assert generator.redact(once) == once
|
||||
|
||||
|
||||
def test_a_long_list_is_trimmed_and_says_so(generator):
|
||||
"""Three rows show the shape, which is the whole job of an example - but a
|
||||
reader must not conclude the endpoint returns three."""
|
||||
scrubbed = generator.redact({'data': list(range(50))})
|
||||
assert scrubbed['data'][:3] == [0, 1, 2]
|
||||
assert 'more' in scrubbed['data'][-1]
|
||||
|
||||
|
||||
def test_a_cyclic_depth_is_bounded(generator):
|
||||
"""Harvested bodies come from a running app, not from this file. A deeply
|
||||
nested one must not take the generator down with it."""
|
||||
deep = current = {}
|
||||
for _ in range(50):
|
||||
current['child'] = {}
|
||||
current = current['child']
|
||||
generator.redact(deep)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The published spec, swept for the same material
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Patterns held HERE, deliberately not imported from the generator. A gate that
|
||||
# checks the scrubber with the scrubber's own patterns can only ever assert that
|
||||
# redact() ran: it was green while the spec published a live bootstrap token and
|
||||
# a live kiosk setup code, because both hid in a URL whose key ('scripturl',
|
||||
# 'url') names no secret and whose value the opaque rule excludes by design.
|
||||
# These must keep catching that class even if someone edits the generator.
|
||||
INDEPENDENT_URL_CREDENTIAL = re.compile(
|
||||
r'(?i)[?&](t|token|code|key|apikey|api_key|secret|sig|signature|password|'
|
||||
r'auth|access_token|refresh_token)=([^&\s"\'<]{8,})')
|
||||
# Product token prefixes. If a new one is minted, add it here the same day.
|
||||
INDEPENDENT_TOKEN_PREFIX = re.compile(r'\b(sdbb_|sdbe_|shopdb_pat_)[A-Za-z0-9_-]{8,}')
|
||||
|
||||
|
||||
def _looks_high_entropy(text):
|
||||
"""A long mixed-case, digit-bearing run with no word structure.
|
||||
|
||||
Deliberately crude: it is a backstop for a secret shape nobody has thought
|
||||
of yet, not a classifier. Anything it flags is either a secret or an example
|
||||
that would read better with a shorter value.
|
||||
"""
|
||||
for chunk in re.split(r'[\s"\'<>]+', text):
|
||||
if len(chunk) < 28 or '.' in chunk or '/' in chunk:
|
||||
continue
|
||||
has = lambda pattern: re.search(pattern, chunk)
|
||||
if has(r'[a-z]') and has(r'[A-Z]') and has(r'\d'):
|
||||
return chunk
|
||||
return None
|
||||
|
||||
|
||||
def test_no_published_example_hides_a_credential_in_a_url(spec):
|
||||
"""The class the scrubber's own patterns cannot see.
|
||||
|
||||
Two real credentials reached docs/openapi.json this way on 2026-09-18, in a
|
||||
file served at /api/docs on production and copied to a published wiki.
|
||||
"""
|
||||
offenders = []
|
||||
for where, example in examples(spec):
|
||||
blob = json.dumps(example)
|
||||
found = INDEPENDENT_URL_CREDENTIAL.search(blob)
|
||||
if found and not found.group(2).startswith('<redacted:'):
|
||||
offenders.append('%s: %s=%s' % (where, found.group(1), found.group(2)[:16]))
|
||||
prefixed = INDEPENDENT_TOKEN_PREFIX.search(blob)
|
||||
if prefixed:
|
||||
offenders.append('%s: token prefix %s' % (where, prefixed.group(0)[:20]))
|
||||
entropy = _looks_high_entropy(blob)
|
||||
if entropy:
|
||||
offenders.append('%s: high-entropy %r' % (where, entropy[:20]))
|
||||
|
||||
assert not offenders, (
|
||||
'Published examples carry what looks like a credential:\n '
|
||||
+ '\n '.join(sorted(set(offenders))))
|
||||
|
||||
|
||||
def test_no_published_example_carries_a_secret_a_person_or_a_machine(spec, generator):
|
||||
"""The gate on the whole harvest, from the side a reader sees.
|
||||
|
||||
Checked against the scrubber's own patterns rather than a second list, so
|
||||
the gate and the scrubber cannot drift apart: this fails if redaction stops
|
||||
being applied, if it is applied to a stale copy, or if a hand-edit puts
|
||||
real material back.
|
||||
"""
|
||||
offenders = []
|
||||
for where, example in examples(spec):
|
||||
for key, value in _walk(example):
|
||||
if not isinstance(value, str) or value.startswith('<redacted:'):
|
||||
continue
|
||||
name = (key or '').lower()
|
||||
# Only strings reach here, and the numeric-id exemption in redact()
|
||||
# is numeric-only, so no key is exempt on this side.
|
||||
if name and generator.SECRET_KEY_PATTERN.search(name):
|
||||
offenders.append('%s: key %r' % (where, key))
|
||||
elif name and generator.PERSON_KEY_PATTERN.search(name):
|
||||
offenders.append('%s: key %r' % (where, key))
|
||||
elif name and generator.HOST_KEY_PATTERN.search(name):
|
||||
offenders.append('%s: key %r' % (where, key))
|
||||
elif generator._redacted_string(value):
|
||||
offenders.append('%s: value under %r' % (where, key))
|
||||
assert not offenders, (
|
||||
'%d published example value(s) look like a secret, a person or a '
|
||||
'machine. docs/openapi.json is served on production and published to '
|
||||
'the wiki; regenerate it with scripts/gen_openapi.py:\n %s'
|
||||
% (len(offenders), '\n '.join(sorted(set(offenders))[:15])))
|
||||
|
||||
|
||||
def _walk(value, key=''):
|
||||
"""(key, value) for every leaf in a harvested example."""
|
||||
if isinstance(value, dict):
|
||||
for itemkey, itemvalue in value.items():
|
||||
for found in _walk(itemvalue, str(itemkey)):
|
||||
yield found
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
for found in _walk(item, key):
|
||||
yield found
|
||||
else:
|
||||
yield key, value
|
||||
|
||||
|
||||
def test_a_published_example_is_bounded(spec, generator):
|
||||
"""Everybody downloads this document and every generated client parses it.
|
||||
One 400-row list response costs all of them."""
|
||||
offenders = [where for where, example in examples(spec)
|
||||
if len(json.dumps(example)) > generator.EXAMPLE_MAX_CHARS]
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
|
||||
def test_a_response_example_never_contradicts_the_schema_beside_it(spec):
|
||||
"""An example and a `$ref` that disagree are worse than no example at all.
|
||||
|
||||
Nineteen routes here do not return the standard envelope and every one was
|
||||
documented as if it did. A harvested transcript is evidence about which,
|
||||
so the generator drops the envelope `$ref` on any operation whose real
|
||||
response is a different shape. This is the gate on that: an operation
|
||||
published with the envelope `$ref` and an example that is not an envelope
|
||||
is the exact contradiction the harvest exists to remove.
|
||||
"""
|
||||
offenders = []
|
||||
for path, verb, operation in operations(spec):
|
||||
media = (((operation.get('responses') or {}).get('200') or {})
|
||||
.get('content') or {}).get('application/json') or {}
|
||||
if 'example' not in media:
|
||||
continue
|
||||
claims = 'SuccessEnvelope' in json.dumps(media.get('schema') or {})
|
||||
isenvelope = (isinstance(media['example'], dict)
|
||||
and media['example'].get('status') == 'success')
|
||||
if claims != isenvelope:
|
||||
offenders.append('%s %s: schema says envelope=%s, example is=%s'
|
||||
% (verb.upper(), path, claims, isenvelope))
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived query parameters: what is emitted, and what is deliberately not
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _derive(tmp_path, source):
|
||||
"""Run the generator's query sweep over one written-out module."""
|
||||
module = tmp_path / 'sample_views.py'
|
||||
module.write_text(textwrap.dedent(source))
|
||||
return _generator()._query_index(str(module))
|
||||
|
||||
|
||||
def test_a_literal_query_read_is_emitted(tmp_path):
|
||||
index = _derive(tmp_path, '''
|
||||
def view():
|
||||
search = request.args.get('search')
|
||||
active = request.args.get('active', 'true')
|
||||
days = request.args.get('days', 30, type=int)
|
||||
tags = request.args.getlist('tag')
|
||||
page = request.args['page']
|
||||
''')
|
||||
found = {item['name']: item for item in index['view']}
|
||||
assert sorted(found) == ['active', 'days', 'page', 'search', 'tag']
|
||||
assert found['search'] == {'name': 'search', 'type': 'string', 'required': False}
|
||||
assert found['active']['default'] == 'true'
|
||||
assert found['days'] == {'name': 'days', 'type': 'integer',
|
||||
'required': False, 'default': 30}
|
||||
assert found['tag']['type'] == 'array'
|
||||
# request.args['x'] raises without the argument. It is the one read in the
|
||||
# language that proves the caller must send it.
|
||||
assert found['page']['required'] is True
|
||||
|
||||
|
||||
def test_a_name_that_is_not_a_literal_is_not_published(tmp_path):
|
||||
"""The honesty rule, and the reason this is safe to derive at all.
|
||||
|
||||
A key built at runtime cannot be resolved, and a parameter that is not
|
||||
really there is worse than a missing one: a reader believes it, sends it,
|
||||
and is silently ignored. A module-level constant DOES resolve, because it
|
||||
is a literal with a name.
|
||||
"""
|
||||
index = _derive(tmp_path, '''
|
||||
TOKEN_ARG = 'bootstraptoken'
|
||||
|
||||
def view(field):
|
||||
built = request.args.get('filter_' + field)
|
||||
indirect = request.args.get(field)
|
||||
formatted = request.args.get(f'{field}id')
|
||||
named = request.args.get(TOKEN_ARG)
|
||||
''')
|
||||
assert [item['name'] for item in index['view']] == ['bootstraptoken']
|
||||
|
||||
|
||||
def test_the_repos_own_argument_helpers_are_read(tmp_path):
|
||||
"""get_pagination_params and intarg/intargs read arguments on a handler's
|
||||
behalf, so a sweep that only looks for request.args misses the paging
|
||||
contract of every list route in the product."""
|
||||
index = _derive(tmp_path, '''
|
||||
def view():
|
||||
page, perpage = get_pagination_params(request)
|
||||
typeid = intarg('typeid')
|
||||
since = intarg('days', 30)
|
||||
rest = intargs('locationid', 'businessunitid')
|
||||
''')
|
||||
found = {item['name']: item for item in index['view']}
|
||||
assert sorted(found) == ['businessunitid', 'days', 'locationid', 'page',
|
||||
'per_page', 'perpage', 'typeid']
|
||||
assert found['page'] == {'name': 'page', 'type': 'integer',
|
||||
'required': False, 'default': 1}
|
||||
assert found['days']['default'] == 30
|
||||
assert found['typeid']['type'] == 'integer'
|
||||
|
||||
|
||||
def test_a_view_that_reads_nothing_publishes_nothing(tmp_path):
|
||||
index = _derive(tmp_path, '''
|
||||
def view(asset_id):
|
||||
return success_response({'assetid': asset_id})
|
||||
''')
|
||||
assert index['view'] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The generator without any of this
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_spec_generates_with_no_harvest_and_no_query_parameters(generator):
|
||||
"""Both halves are OPTIONAL. A published checkout has no tests to harvest
|
||||
from, and an inventory written before this existed has no `queryparams`. A
|
||||
generator that needs either is a generator that cannot be run where it
|
||||
matters."""
|
||||
built = generator.build([{
|
||||
'surface': 'core-assets',
|
||||
'endpoints': [{
|
||||
'method': 'GET|POST',
|
||||
'path': '/api/assets',
|
||||
'auth': 'permission:assets.view',
|
||||
'params': 'not documented',
|
||||
'purpose': 'List assets.',
|
||||
'example': 'curl http://localhost:5001/api/assets',
|
||||
}],
|
||||
}])
|
||||
operation = built['paths']['/api/assets']['get']
|
||||
assert 'parameters' not in operation
|
||||
media = built['paths']['/api/assets']['post']['requestBody']['content']
|
||||
assert 'example' not in media['application/json']
|
||||
success = operation['responses']['200']['content']['application/json']
|
||||
assert 'example' not in success
|
||||
assert success['schema'] == {'$ref': '#/components/schemas/SuccessEnvelope'}
|
||||
|
||||
|
||||
def test_a_query_parameter_survives_into_the_spec(generator):
|
||||
"""The other direction: an inventory entry that has them publishes them."""
|
||||
built = generator.build([{
|
||||
'surface': 'core-setup',
|
||||
'endpoints': [{
|
||||
'method': 'GET',
|
||||
'path': '/api/setup/claim',
|
||||
'auth': 'setup-code (single-use setup code in ?code=)',
|
||||
'params': 'not documented',
|
||||
'purpose': 'Trade a setup code for this browser session.',
|
||||
'example': 'curl http://localhost:5001/api/setup/claim',
|
||||
'queryparams': [{'name': 'code', 'type': 'string', 'required': False}],
|
||||
'responseexample': {'status': 'success', 'data': None},
|
||||
}],
|
||||
}])
|
||||
operation = built['paths']['/api/setup/claim']['get']
|
||||
assert operation['parameters'] == [{
|
||||
'name': 'code', 'in': 'query', 'required': False,
|
||||
'schema': {'type': 'string'},
|
||||
}]
|
||||
success = operation['responses']['200']['content']['application/json']
|
||||
assert success['example'] == {'status': 'success', 'data': None}
|
||||
# A query filter that matches nothing is an empty list and a 200. Only a
|
||||
# path parameter can be a record that is not there.
|
||||
assert '404' not in operation['responses']
|
||||
|
||||
|
||||
def test_a_per_request_value_is_pinned_but_keeps_its_shape(generator):
|
||||
"""A transcript carries the instant it was taken and the id of the request
|
||||
that produced it, and both differ on every run.
|
||||
|
||||
Published verbatim, re-harvesting rewrote 313 of 320 examples with no change
|
||||
of meaning - an unreviewable diff over a committed, published file. An
|
||||
example shows a reader the FORMAT of a field, not the moment one call
|
||||
happened, so the format is kept exactly and the value is pinned.
|
||||
"""
|
||||
scrubbed = generator.redact({
|
||||
'meta': {'timestamp': '2026-09-18T18:30:48.498252Z',
|
||||
'requestid': '859aa86b'},
|
||||
'createddate': '2026-09-18 08:15:00',
|
||||
'expiresat': '2026-12-31T23:59:59+00:00',
|
||||
'installedon': '2026-09-18',
|
||||
})
|
||||
# Microseconds, the space separator and the offset all survive: those are
|
||||
# three different contracts and a reader is reading the example for which.
|
||||
assert scrubbed['meta']['timestamp'] == '2026-01-01T00:00:00.000000Z'
|
||||
assert scrubbed['meta']['requestid'] == '00000000'
|
||||
assert scrubbed['createddate'] == '2026-01-01 00:00:00'
|
||||
assert scrubbed['expiresat'] == '2026-01-01T00:00:00+00:00'
|
||||
assert scrubbed['installedon'] == '2026-01-01'
|
||||
|
||||
|
||||
def test_pinning_does_not_reach_ordinary_text(generator):
|
||||
"""Only a value that IS a timestamp. A model number that happens to contain
|
||||
digits is not one."""
|
||||
payload = {'model': '5420-2026', 'note': 'due 2026-09-18 at the latest',
|
||||
'version': '1.2.3'}
|
||||
assert generator.redact(payload) == payload
|
||||
|
||||
|
||||
def test_a_local_path_is_not_published(generator):
|
||||
"""A path off the machine the harvest ran on says nothing to a reader and
|
||||
is different on every run. A developer's home directory is also the exact
|
||||
material tests/test_docs_publishable.py exists to keep off a public page."""
|
||||
scrubbed = generator.redact({
|
||||
'shareroot': '/tmp/pytest-of-someone/pytest-240/test_roundtrip0',
|
||||
'checkout': '/home/someone/projects/shopdb-flask',
|
||||
'relative': 'scripts/site_imports',
|
||||
'route': '/api/assets',
|
||||
})
|
||||
assert scrubbed['shareroot'] == '<redacted:path>'
|
||||
assert scrubbed['checkout'] == '<redacted:path>'
|
||||
# A path that names no machine is the useful half, and it stays.
|
||||
assert scrubbed['relative'] == 'scripts/site_imports'
|
||||
assert scrubbed['route'] == '/api/assets'
|
||||
|
||||
|
||||
def test_a_harvest_adds_and_never_clears(generator, tmp_path):
|
||||
"""A targeted harvest has to be targeted.
|
||||
|
||||
Clearing on absence meant `--harvest tests/test_core` stripped the examples
|
||||
off every route those tests do not touch, which is most of them - the
|
||||
narrow command would have done the most damage. It also made the committed
|
||||
file churn for nothing: run under xdist against a shared instance
|
||||
directory, two full runs of the same tree reached 311 and 327 operations.
|
||||
"""
|
||||
inventory = tmp_path / 'api-inventory.json'
|
||||
inventory.write_text(json.dumps([{
|
||||
'surface': 'core-assets',
|
||||
'endpoints': [
|
||||
{'method': 'GET', 'path': '/api/assets',
|
||||
'responseexample': {'status': 'success', 'data': ['kept']}},
|
||||
{'method': 'POST', 'path': '/api/assets'},
|
||||
],
|
||||
}]))
|
||||
original = generator.INVENTORY
|
||||
generator.INVENTORY = str(inventory)
|
||||
try:
|
||||
generator.fold_examples({
|
||||
('POST', '/api/assets'): {
|
||||
'request': [{'assetnumber': 'A-1'}],
|
||||
'response': [{'status': 'success', 'data': {'assetid': 1}}],
|
||||
},
|
||||
})
|
||||
finally:
|
||||
generator.INVENTORY = original
|
||||
entries = json.loads(inventory.read_text())[0]['endpoints']
|
||||
# Untouched by this harvest, and still carrying what it had.
|
||||
assert entries[0]['responseexample'] == {'status': 'success', 'data': ['kept']}
|
||||
assert entries[1]['requestexample'] == {'assetnumber': 'A-1'}
|
||||
assert entries[1]['responseexample'] == {'status': 'success',
|
||||
'data': {'assetid': 1}}
|
||||
|
||||
|
||||
def test_picking_one_example_out_of_many_is_deterministic(generator):
|
||||
"""The suite runs in parallel, so the order samples arrive in is not stable.
|
||||
|
||||
Largest-that-fits with a text tiebreak means two runs over the same samples
|
||||
produce the same spec, which is what makes a regenerated docs/openapi.json
|
||||
reviewable as a diff instead of a rewrite.
|
||||
"""
|
||||
samples = [
|
||||
{'status': 'success', 'data': {'assetid': 1}},
|
||||
{'status': 'success', 'data': {'assetid': 2, 'assetnumber': 'A-2'}},
|
||||
{'status': 'success', 'data': None},
|
||||
]
|
||||
picked = generator._best_example(samples)
|
||||
assert picked == generator._best_example(list(reversed(samples)))
|
||||
assert picked['data']['assetnumber'] == 'A-2'
|
||||
|
||||
|
||||
def test_an_example_that_cannot_be_made_to_fit_is_dropped(generator):
|
||||
"""Silence beats a truncated JSON document: half an object is not an
|
||||
example of anything, and it is not what the route returned."""
|
||||
# A wide object, not a long string: strings and lists are already capped
|
||||
# inside redact, so an object with hundreds of keys is the shape that can
|
||||
# still come out over the limit.
|
||||
huge = {'status': 'success',
|
||||
'data': {'field%03d' % index: 'value' for index in range(500)}}
|
||||
assert generator._best_example([huge]) is None
|
||||
# And a smaller sample of the same operation still wins, rather than the
|
||||
# whole operation losing its example to one oversized transcript.
|
||||
small = {'status': 'success', 'data': {'assetid': 1}}
|
||||
assert generator._best_example([huge, small]) == small
|
||||
|
||||
|
||||
def test_a_delete_that_reads_a_body_says_so(generator):
|
||||
"""DELETE /api/applications/{id} refuses without {"confirm": "OK"}.
|
||||
|
||||
The generator declared a body for POST, PUT and PATCH only, so that route
|
||||
published a call that cannot succeed and a client built from the spec fails
|
||||
on the day.
|
||||
|
||||
Evidence is the HANDLER reading a body (`readsbody`, derived from its AST),
|
||||
not a harvested transcript. A transcript proves the test CLIENT sent
|
||||
something, and the suite sends {} to routes that ignore it: on 2026-09-18
|
||||
transcript-only evidence published "this route reads a JSON body" on DELETE
|
||||
/api/endpoints/agentkey, whose handler never calls get_json. Both halves are
|
||||
pinned below.
|
||||
"""
|
||||
entries = [
|
||||
{'method': 'DELETE', 'path': '/api/applications/<int:app_id>',
|
||||
'auth': 'permission:applications.delete', 'purpose': 'Delete it.',
|
||||
'readsbody': True, 'requestexample': {'confirm': 'OK'}},
|
||||
{'method': 'DELETE', 'path': '/api/assets/<int:assetid>',
|
||||
'auth': 'permission:assets.delete', 'purpose': 'Delete it.'},
|
||||
# A transcript with no handler evidence: the suite sent a body, the
|
||||
# route ignores it, so the contract must stay silent.
|
||||
{'method': 'DELETE', 'path': '/api/endpoints/agentkey',
|
||||
'auth': 'permission:endpoints.manage', 'purpose': 'Revoke it.',
|
||||
'requestexample': {}},
|
||||
]
|
||||
built = generator.build([{'surface': 'core-applications', 'endpoints': entries}])
|
||||
withbody = built['paths']['/api/applications/{app_id}']['delete']
|
||||
media = withbody['requestBody']['content']['application/json']
|
||||
assert withbody['requestBody']['required'] is False
|
||||
assert media['example'] == {'confirm': 'OK'}
|
||||
# Transcript without handler evidence declares nothing.
|
||||
ignored = built['paths']['/api/endpoints/agentkey']['delete']
|
||||
assert 'requestBody' not in ignored
|
||||
assert 'requestBody' not in built['paths']['/api/assets/{assetid}']['delete']
|
||||
@@ -72,15 +72,83 @@ def test_write_verbs_can_carry_a_body(spec):
|
||||
'from this spec cannot send one:\n ' + '\n '.join(offenders[:15]))
|
||||
|
||||
|
||||
def documented_auth(operation):
|
||||
"""The auth string the generator wrote into this operation's description.
|
||||
|
||||
The spec is the only thing this module reads, and the description is where
|
||||
the human-readable auth lands ('**Auth:** jwt-optional (...)'). Comparing
|
||||
the published `security` against it is how a lie gets caught: the two come
|
||||
from the same inventory entry and must agree.
|
||||
"""
|
||||
match = re.search(r'\*\*Auth:\*\*\s*(.+)', operation.get('description') or '')
|
||||
return match.group(1).strip().lower() if match else ''
|
||||
|
||||
|
||||
def test_optional_auth_is_expressed_as_optional(spec):
|
||||
"""An endpoint that works logged out must not be published as requiring a
|
||||
token - that is a lie to every reader, and it is the majority case here."""
|
||||
optional = [operation for _, _, operation in operations(spec)
|
||||
if any(item == {} for item in operation.get('security') or [])]
|
||||
assert len(optional) > 50, (
|
||||
'The inventory documents over a hundred jwt-optional endpoints; the '
|
||||
'spec should express them with an empty security requirement '
|
||||
'alongside the scheme, not as bearer-required.')
|
||||
"""A route whose auth is optional is published as optional, either way round.
|
||||
|
||||
WHY THE COUNT IS GONE. This asserted `len(optional) > 50`, on the premise
|
||||
that optional auth was the majority case here - it said so in as many
|
||||
words. It is not any more. The guest role and the permission model moved
|
||||
the read surface to `permission:*`, which publishes as bearer-required and
|
||||
is correct, and the optional count fell to 14. The assertion went red
|
||||
without a single route being wrong.
|
||||
|
||||
A count was never the property anyone cares about. It goes red when the
|
||||
codebase legitimately changes shape, and it stays green while an individual
|
||||
optional route is published as bearer-required, which is the actual defect.
|
||||
Lowering the number to 14 would only buy time until the next legitimate
|
||||
move. So the premise is dropped and the per-route property is checked
|
||||
instead, whatever the total: an operation whose auth says optional carries
|
||||
BOTH the empty requirement and a scheme, and an operation that publishes
|
||||
the empty requirement says optional in its auth.
|
||||
"""
|
||||
offenders = []
|
||||
for path, verb, operation in operations(spec):
|
||||
auth = documented_auth(operation)
|
||||
security = operation.get('security') or []
|
||||
openly = any(item == {} for item in security)
|
||||
scheme = any(item for item in security)
|
||||
if 'optional' in auth:
|
||||
if not openly:
|
||||
offenders.append(
|
||||
'%s %s: auth says optional, published as required (%r)'
|
||||
% (verb.upper(), path, security))
|
||||
elif not scheme:
|
||||
offenders.append(
|
||||
'%s %s: auth says optional, published with no scheme at '
|
||||
'all, so a caller is never told what the credential is'
|
||||
% (verb.upper(), path))
|
||||
elif openly:
|
||||
offenders.append(
|
||||
'%s %s: published as optional, but its auth says %r'
|
||||
% (verb.upper(), path, auth))
|
||||
assert not offenders, (
|
||||
'Optional auth means "works without a credential, does more with one", '
|
||||
'and OpenAPI writes it as the empty requirement ALONGSIDE the '
|
||||
'scheme:\n ' + '\n '.join(offenders[:15]))
|
||||
|
||||
|
||||
def test_an_operation_needing_no_credential_says_so_in_its_auth(spec):
|
||||
"""`security: []` is a machine-readable claim that no credential is needed.
|
||||
|
||||
Nine operations published it while their handler required one, including
|
||||
the two that hand a bootstrapping PC its agent package - `security: []` and
|
||||
a 404 for anyone who does not present the single-use token. The generator
|
||||
reached that by substring: the auth string started with the word 'none', in
|
||||
a sentence that went on to name the token. This is the gate on that class
|
||||
of defect from the spec side, where a reader or a generated client sees it.
|
||||
"""
|
||||
offenders = [
|
||||
'%s %s: %r' % (verb.upper(), path, documented_auth(operation))
|
||||
for path, verb, operation in operations(spec)
|
||||
if not operation.get('security')
|
||||
and 'none' not in documented_auth(operation)
|
||||
and 'public' not in documented_auth(operation)
|
||||
]
|
||||
assert not offenders, (
|
||||
'These operations publish "no credentials needed" while their auth '
|
||||
'names a credential:\n ' + '\n '.join(offenders[:15]))
|
||||
|
||||
|
||||
def test_an_endpoint_that_needs_no_token_cannot_fail_with_401(spec):
|
||||
@@ -121,3 +189,58 @@ def test_summaries_are_not_cut_mid_word(spec):
|
||||
if body and not body[-1].isalnum() and body[-1] not in ')]"\'':
|
||||
offenders.append('%s %s: %r' % (verb.upper(), path, summary[-40:]))
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
|
||||
def test_every_parameter_is_well_formed(spec):
|
||||
"""A parameter with no name, no location or no schema is not a parameter.
|
||||
|
||||
Query parameters are derived from each handler's AST now rather than left
|
||||
out, which is 87 operations that could not be filtered, searched or paged
|
||||
from this document before. Derived means a code change can produce a
|
||||
malformed one, so the shape is checked here rather than assumed.
|
||||
"""
|
||||
offenders = []
|
||||
for path, verb, operation in operations(spec):
|
||||
for parameter in operation.get('parameters', []):
|
||||
where = '%s %s: %r' % (verb.upper(), path, parameter.get('name'))
|
||||
if not parameter.get('name'):
|
||||
offenders.append('%s %s: a parameter with no name' % (verb.upper(), path))
|
||||
if parameter.get('in') not in ('path', 'query', 'header', 'cookie'):
|
||||
offenders.append(where + ' has no valid `in`')
|
||||
if not isinstance(parameter.get('required'), bool):
|
||||
offenders.append(where + ' does not say whether it is required')
|
||||
if not parameter.get('schema'):
|
||||
offenders.append(where + ' has no schema')
|
||||
if parameter.get('in') == 'path' and not parameter.get('required'):
|
||||
offenders.append(where + ' is in the path and optional, which '
|
||||
'OpenAPI does not allow')
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
|
||||
def test_no_parameter_is_declared_twice(spec):
|
||||
"""(name, in) is the identity of a parameter. Two entries for one name is
|
||||
ambiguous to a generated client, which picks one and drops the other."""
|
||||
offenders = []
|
||||
for path, verb, operation in operations(spec):
|
||||
seen = set()
|
||||
for parameter in operation.get('parameters', []):
|
||||
key = (parameter.get('name'), parameter.get('in'))
|
||||
if key in seen:
|
||||
offenders.append('%s %s: %s in %s'
|
||||
% (verb.upper(), path, key[0], key[1]))
|
||||
seen.add(key)
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
|
||||
def test_a_query_parameter_is_not_the_path_argument_under_another_name(spec):
|
||||
"""`/api/assets/{assetid}?assetid=` is a contradiction: the same value in
|
||||
two places, and a generated client has to guess which one the route reads."""
|
||||
offenders = []
|
||||
for path, verb, operation in operations(spec):
|
||||
intemplate = set(re.findall(r'\{([^}]+)\}', path))
|
||||
query = {p['name'] for p in operation.get('parameters', [])
|
||||
if p.get('in') == 'query'}
|
||||
clash = sorted(intemplate & query)
|
||||
if clash:
|
||||
offenders.append('%s %s: %s' % (verb.upper(), path, clash))
|
||||
assert not offenders, '\n '.join(offenders[:15])
|
||||
|
||||
@@ -17,7 +17,7 @@ from shopdb.plugins import plugin_manager
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
|
||||
BUNDLED_PLUGINS = ('computers', 'employees', 'geenforce', 'knowledgebase', 'machines', 'measuringtools', 'network', 'notifications', 'printers', 'slides', 'usb', 'warranty')
|
||||
BUNDLED_PLUGINS = ('backups', 'computers', 'endpoints', 'guides', 'knowledgebase', 'machines', 'network', 'notifications', 'printers', 'slides', 'tools', 'warranty')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -28,7 +28,10 @@ def _write_pkg_plugin(plugins_dir, name, marker='source'):
|
||||
'core_version': '>=0.1.0,<1.0.0', 'api_prefix': f'/api/{name}',
|
||||
}))
|
||||
(pdir / '__init__.py').write_text('')
|
||||
(pdir / 'plugin.py').write_text('# plugin\n')
|
||||
# write_bytes, not write_text: on Windows write_text translates \n to
|
||||
# \r\n, which changes the bytes that get signed and hashed and makes the
|
||||
# verified_source assertion below fail for a purely platform reason.
|
||||
(pdir / 'plugin.py').write_bytes(b'# plugin\n')
|
||||
(pdir / 'models' / '__init__.py').write_text(f'MARKER = {marker!r}\n')
|
||||
return pdir
|
||||
|
||||
@@ -81,7 +84,7 @@ def test_verified_source_signed_ok_tampered_raises(guarded):
|
||||
|
||||
assert guard.verified_source(name, 'plugin.py') == b'# plugin\n'
|
||||
|
||||
(pdir / 'plugin.py').write_text('# tampered\n')
|
||||
(pdir / 'plugin.py').write_bytes(b'# tampered\n')
|
||||
with pytest.raises(PluginVerificationError):
|
||||
guard.verified_source(name, 'plugin.py')
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ These tests pin that contract:
|
||||
* `flask plugin upgrade-all` runs clean on a fresh DB and is idempotent.
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -30,26 +31,33 @@ PLUGINS_DIR = Path(__file__).resolve().parent.parent / 'plugins'
|
||||
# not. Today every bundled plugin owns tables, so this is the full set.
|
||||
TABLE_OWNING_PLUGINS = tuple(sorted(PLUGIN_TABLE_OWNERS))
|
||||
|
||||
# The ADR-008 cutover froze this exact set of ten plugins whose tables the core
|
||||
# chain had already created. Their 0001 revision is a stamp-only no-op anchor.
|
||||
# Plugins built AFTER the cutover (e.g. measuringtools) are NOT in this list:
|
||||
# their 0001 is a real baseline that genuinely creates their tables, so the
|
||||
# no-op assertion must not apply to them. This is a frozen list on purpose - a
|
||||
# newly discovered plugin does not silently get treated as a cutover no-op.
|
||||
# The ADR-008 cutover froze the set of plugins whose tables the core chain had
|
||||
# already created. Their 0001 revision is a stamp-only no-op anchor. Plugins
|
||||
# built AFTER the cutover are NOT in this list: their 0001 is a real baseline
|
||||
# that genuinely creates their tables, so the no-op assertion must not apply to
|
||||
# them. This is a frozen list on purpose - a newly discovered plugin does not
|
||||
# silently get treated as a cutover no-op.
|
||||
#
|
||||
# Two cutover plugins do not ship on this line (employees, usb). Their anchors
|
||||
# went with them; the CORE migrations that created their tables did not, and
|
||||
# must not - every anchor here stamps against that same core head. The tables
|
||||
# they leave behind are listed in ORPHAN_TABLE_OWNERS so prune-schema can drop
|
||||
# them.
|
||||
CUTOVER_PLUGINS = (
|
||||
'computers', 'employees', 'knowledgebase', 'machines', 'network',
|
||||
'notifications', 'printers', 'slides', 'usb', 'warranty',
|
||||
'computers', 'knowledgebase', 'machines', 'network',
|
||||
'notifications', 'printers', 'slides', 'warranty',
|
||||
)
|
||||
|
||||
# Expected head revision id per table-owning plugin, so the upgrade-all test can
|
||||
# check both the cutover anchors and post-cutover baselines. Cutover plugins
|
||||
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
|
||||
# stamp '<plugin>0001anchor'; a post-cutover plugin stamps its real baseline id.
|
||||
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
|
||||
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
||||
# computers adds the Entra/Azure device identity columns (azuredeviceid, then
|
||||
# azureobjectid + azuredevicecategory), then the Configuration/Business policy
|
||||
# tables, on top of its anchor, so its head is not the f-string default.
|
||||
EXPECTED_HEAD_REVISION['computers'] = 'computers0004policies'
|
||||
# tables, then the break-glass policyactionholds table, on top of its anchor, so
|
||||
# its head is not the f-string default. 0006 widens policyactionholds.expiresat
|
||||
# to nullable, where null means an indefinite hold that never self-clears.
|
||||
EXPECTED_HEAD_REVISION['computers'] = 'computers0006holdindefinite'
|
||||
# backups is also post-cutover: its 0001 really creates backuprevisions, and
|
||||
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
|
||||
# otherwise throws away.
|
||||
@@ -57,9 +65,6 @@ EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
|
||||
# guides is a NEW plugin, so its 0001 builds tables rather than stamping an
|
||||
# anchor over core-owned ones.
|
||||
EXPECTED_HEAD_REVISION['guides'] = 'guides0001baseline'
|
||||
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||
# baseline.
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
|
||||
# printers adds the printersupplyalerts crossing-state table on top of its
|
||||
# anchor, then the exact INF driver name Add-PrinterDriver needs, a vendor on
|
||||
# the driver, and finally printerobservedqueues - what a bay reported it HAS,
|
||||
@@ -68,26 +73,17 @@ EXPECTED_HEAD_REVISION['printers'] = 'printers0005observedqueues'
|
||||
# machines (renamed from equipment) keeps its original anchor id and adds the
|
||||
# rename revision on top, so its head is not the f-string default.
|
||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
# employees adds the photofilename column on top of its cutover anchor.
|
||||
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor, then
|
||||
# adds scannedviruses so an imported external history keeps the scan result -
|
||||
# the field a removable-media audit actually asks about - and finally records
|
||||
# who handed a drive back, which is not always who took it out.
|
||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0004checkinidentity'
|
||||
# network links a device to a catalog model, which is where its photo comes
|
||||
# from - machines, PCs and printers already had that link.
|
||||
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
|
||||
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
|
||||
# the per-transaction revision column.
|
||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# Built after the ADR-008 cutover, so its 0001 genuinely creates the table
|
||||
# rather than stamping an anchor. Tech Tools owns a table now because secure
|
||||
# send needs one; every other tool in that plugin is browser-side.
|
||||
EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend'
|
||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||
# the shared board category.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
||||
# the shared board category, then the fleet-alert columns plus the per-asset
|
||||
# acknowledgement table the endpoint agent writes to.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0006fleetalerts'
|
||||
# warranty adds the proof-of-cover document columns on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||
# endpoints is a NEW post-cutover plugin: its 0001 genuinely creates the
|
||||
@@ -101,7 +97,25 @@ EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||
# 0010 renames the shipped business policy off the originating site's
|
||||
# designator on installs that already stamped 0007 (ADR-015).
|
||||
# 0011 adds the trayversion cache column to endpointstates.
|
||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0011trayversion'
|
||||
# 0012 adds endpointevents.eventrecordid plus the unique index that dedups a
|
||||
# replayed Windows event record.
|
||||
# 0013 adds the inventory columns.
|
||||
# 0014 adds endpointstates.diskhealthjson, and 0015 adds the system telemetry
|
||||
# columns (boot info, reboot reasons, Defender health, NIC link state).
|
||||
# 0016 adds the Dell update tables.
|
||||
# 0017 adds the health rollup: endpointhealthstates, endpointhealthtransitions
|
||||
# and endpointmonitorimpacts.
|
||||
# 0018 adds endpointlocks: the admin quarantine refusing actions from non-admins
|
||||
# on one endpoint, a caller-authority axis separate from the policy hold.
|
||||
# 0019 splits the 0007 bundle into one shipped Configuration Policy per monitor
|
||||
# and drops the shipped Business Policy, so sites compose their own.
|
||||
# 0020 adds endpointapplications.appfingerprint for delta app updates. 0021 and
|
||||
# 0022 are retired no-ops (client certificates, removed). 0023 drops their
|
||||
# leftover asset columns, renames app_fingerprint, and adds per-PC credentials:
|
||||
# endpointcredentials, endpointenrollrequests and endpointmigrationwaves.
|
||||
# 0024 adds endpointbootstraptokens: the short-lived, admin-minted tokens behind
|
||||
# the one-line bootstrap installer.
|
||||
EXPECTED_HEAD_REVISION['endpoints'] = 'endpoints0024bootstraptokens'
|
||||
|
||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||
# core chain never owned.
|
||||
@@ -194,6 +208,17 @@ def test_plugin_has_migration_chain(plugin):
|
||||
if isinstance(node.value, ast.Constant):
|
||||
down = node.value.value
|
||||
assert rev, f"{plugin}: {script.name} has no revision id"
|
||||
# alembic_version_<plugin>.version_num is VARCHAR(32). A longer id
|
||||
# passes every SQLite test and then fails the real deploy on strict
|
||||
# MySQL with "Data too long for column 'version_num'", after the
|
||||
# migration body has already run. Caught
|
||||
# endpoints0019splitmonitorpolicies (33 chars) during a container roll.
|
||||
# The CORE chain's alembic_version is VARCHAR(128), so core ids are not
|
||||
# bound by this and are not checked here.
|
||||
assert len(rev) <= 32, (
|
||||
f"{plugin}: {script.name} revision id {rev!r} is {len(rev)} chars; "
|
||||
'alembic_version.version_num holds 32'
|
||||
)
|
||||
assert found_down, f"{plugin}: {script.name} has no down_revision"
|
||||
revisions.add(rev)
|
||||
if down is None:
|
||||
@@ -211,7 +236,7 @@ def test_anchor_migration_is_noop(plugin):
|
||||
operations, just `pass`. The core chain owns the tables at cutover.
|
||||
|
||||
Scoped to CUTOVER_PLUGINS only. A plugin built after the cutover ships a
|
||||
real baseline (measuringtools), which is deliberately NOT a no-op."""
|
||||
real baseline, which is deliberately NOT a no-op."""
|
||||
anchor = PLUGINS_DIR / plugin / 'migrations' / 'versions' / f'0001_{plugin}_anchor.py'
|
||||
assert anchor.exists(), f"{plugin}: missing 0001 anchor migration"
|
||||
tree = ast.parse(anchor.read_text())
|
||||
@@ -306,7 +331,7 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
|
||||
# a longer chain meets a second problem - an ALTER whose column is already
|
||||
# present - that guarding a create_table does not fix. True, but the conclusion
|
||||
# was wrong: the answer is to guard the ALTERs too, not to stop testing the
|
||||
# chains that have them. While this was narrowed, printedparts 0003 sat
|
||||
# chains that have them. While this was narrowed, one plugin's 0003 sat
|
||||
# unguarded for a month and would fail upgrade-all on any create-all-built
|
||||
# database, taking 0004 down with it, with no test able to see it.
|
||||
#
|
||||
@@ -387,3 +412,702 @@ def test_baseline_skips_tables_that_already_exist(plugin, tmp_path, monkeypatch)
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
|
||||
def test_upgrade_all_seeds_permissions_and_links_them_to_scopes(
|
||||
tmp_path, monkeypatch):
|
||||
"""`flask plugin upgrade-all` seeds plugin permissions AND relinks scopes.
|
||||
|
||||
on_install/on_enable fire only on a state transition, so a permission
|
||||
declared by a LATER plugin version never reaches an already-installed site
|
||||
through them. Upgrade is the only remaining moment. Before this was wired,
|
||||
the name existed only after a manual `flask seed permissions`.
|
||||
|
||||
The scope relink is the half that actually bites. Creating the Permission
|
||||
row only makes the name exist - the prebuilt scopes derive their grants from
|
||||
the catalog, so a permission seeded without a following seedscopes() belongs
|
||||
to nobody but admin. The plugin installs clean and its UI is invisible to
|
||||
every non-admin, which reads as a broken plugin rather than a seeding gap.
|
||||
"""
|
||||
from shopdb.config import TestingConfig
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Permission, Role
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
db_file = tmp_path / 'perms.db'
|
||||
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI',
|
||||
f'sqlite:///{db_file}')
|
||||
|
||||
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes)
|
||||
try:
|
||||
app = create_app('testing')
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
Permission.seed()
|
||||
Role.seedscopes()
|
||||
db.session.commit()
|
||||
|
||||
pm = app.extensions['plugin_manager']
|
||||
|
||||
# Stand in for a permission that a later plugin version declares:
|
||||
# delete a plugin-owned view permission so upgrade-all has to put it
|
||||
# back the way it would for a genuinely new one.
|
||||
target = Permission.query.filter_by(name='endpoints.view').first()
|
||||
assert target is not None, 'fixture expects endpoints.view to exist'
|
||||
for role in list(target.roles):
|
||||
role.permissions.remove(target)
|
||||
db.session.delete(target)
|
||||
db.session.commit()
|
||||
assert Permission.query.filter_by(name='endpoints.view').first() is None
|
||||
|
||||
pm.upgrade_all_plugins()
|
||||
|
||||
# The row is back...
|
||||
restored = Permission.query.filter_by(name='endpoints.view').first()
|
||||
assert restored is not None, (
|
||||
'upgrade-all did not re-seed a missing plugin permission')
|
||||
|
||||
# ...and it is actually granted to the read-capable scopes, not
|
||||
# merely present. This is the assertion that fails if seedscopes()
|
||||
# is dropped from the upgrade path.
|
||||
holders = {role.rolename for role in restored.roles}
|
||||
assert 'technician' in holders, (
|
||||
f'endpoints.view reseeded but not linked to any scope: {holders}')
|
||||
finally:
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADR-008 lane guard: a plugin migration may only do DDL on its OWN tables.
|
||||
#
|
||||
# The endpoints chain broke this and nothing noticed. 0021 (shipped, since
|
||||
# rewritten to a no-op stub) added five client-certificate columns plus
|
||||
# idx_assets_cert_fingerprint to the CORE assets table from inside a plugin
|
||||
# chain, and 0023 drops them again. Both directions are guarded and idempotent
|
||||
# now, so the breach self-heals on every database - but self-healing is not
|
||||
# prevention, and the only thing that stopped the next one was that somebody
|
||||
# happened to read the file. That is what this guard replaces.
|
||||
#
|
||||
# The rule, stated as ADR-008 states it: the core Alembic chain owns core
|
||||
# tables, each plugin chain owns the tables listed for it in
|
||||
# PLUGIN_TABLE_OWNERS, and no chain reaches across. `assets` is not in
|
||||
# PLUGIN_TABLE_OWNERS['endpoints'] and cannot be, so the per-plugin MetaData
|
||||
# filter never saw the columns either: the ownership map was right and the
|
||||
# migration simply ignored it.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CORE_MODELS_DIR = Path(__file__).resolve().parent.parent / 'shopdb' / 'core' / 'models'
|
||||
|
||||
# Alembic op name -> position of the table name in its positional arguments.
|
||||
# create_index/drop_index take the INDEX name first and the table second, and
|
||||
# every constraint op takes the CONSTRAINT name first, which is the whole
|
||||
# reason this is a map and not "args[0]".
|
||||
#
|
||||
# The constraint ops are here because they are DDL on somebody's table just as
|
||||
# much as add_column is: create_foreign_key / drop_constraint /
|
||||
# create_unique_constraint / create_check_constraint / create_primary_key can
|
||||
# all add or drop a constraint on core `assets` from inside a plugin chain.
|
||||
# Leaving them out let that pass silently.
|
||||
#
|
||||
# create_foreign_key's REFERENT table is deliberately not recorded. The DDL
|
||||
# runs against the source table only; naming another lane's table as a foreign
|
||||
# key target is a read of its shape, not a write to it, and plugins legitimately
|
||||
# point at core parents (network 0002 references `models`).
|
||||
_TABLE_ARG_POSITION = {
|
||||
'create_table': 0,
|
||||
'drop_table': 0,
|
||||
'add_column': 0,
|
||||
'drop_column': 0,
|
||||
'alter_column': 0,
|
||||
'rename_table': 0,
|
||||
'create_index': 1,
|
||||
'drop_index': 1,
|
||||
'create_foreign_key': 1,
|
||||
'drop_constraint': 1,
|
||||
'create_unique_constraint': 1,
|
||||
'create_check_constraint': 1,
|
||||
'create_primary_key': 1,
|
||||
}
|
||||
|
||||
# Keywords that carry the table when it is not passed positionally. 0023 uses
|
||||
# `table_name='assets'`, which an args-only scan walks straight past. `source`
|
||||
# and `source_table` are the two spellings Alembic has used for
|
||||
# create_foreign_key's own table; `referent`/`referent_table` are absent on
|
||||
# purpose, for the reason given above.
|
||||
_TABLE_KEYWORDS = ('table_name', 'source', 'source_table', 'old_table_name')
|
||||
|
||||
# Op name -> position of the COLUMN name in its positional arguments, for the
|
||||
# ops that name exactly one column. The allowlist keys on this as well as the
|
||||
# table, so an entry excusing a shipped drop_column cannot also excuse a NEW
|
||||
# drop_column on the same table in the same file.
|
||||
_COLUMN_ARG_POSITION = {
|
||||
'add_column': 1,
|
||||
'drop_column': 1,
|
||||
'alter_column': 1,
|
||||
}
|
||||
|
||||
# Same, for the batch_alter_table forms, where the table moved onto the context
|
||||
# manager and every positional argument shifted left by one.
|
||||
_BATCH_COLUMN_ARG_POSITION = {
|
||||
'add_column': 0,
|
||||
'drop_column': 0,
|
||||
'alter_column': 0,
|
||||
}
|
||||
|
||||
# --- raw SQL -----------------------------------------------------------------
|
||||
# op.execute() and Connection.exec_driver_sql() hand a string straight to the
|
||||
# database, so an AST scan that only knows the op.* helpers sees zero ops for
|
||||
# `op.execute('ALTER TABLE assets DROP COLUMN assetnumber')`. That is the
|
||||
# cheapest possible way around every other guard in this file, so the statement
|
||||
# text is parsed too.
|
||||
#
|
||||
# Only DDL against a named table counts. UPDATE/DELETE/INSERT/SELECT are data,
|
||||
# not schema: three plugin revisions already run cross-checked DML
|
||||
# (backups 0002, backups 0003, computers 0006) and none of them is a lane
|
||||
# crossing.
|
||||
#
|
||||
# Unresolvable text is rendered as a sentinel character rather than dropped, so
|
||||
# the difference between "interpolates the constraint name" and "interpolates
|
||||
# the table name" survives into the regex. machines 0002 does the first
|
||||
# (`f"ALTER TABLE equipment DROP FOREIGN KEY {name}"` - the table is literal
|
||||
# text, the FK name is not) and stays resolvable; the dodge this guards against
|
||||
# is the second (`f'ALTER TABLE {t} DROP COLUMN x'`), where the sentinel lands
|
||||
# inside the captured table name and the op reports UNRESOLVED, which
|
||||
# test_plugin_migration_ops_all_resolve_to_a_table treats as a failure.
|
||||
_SQL_HOLE = '\x00'
|
||||
|
||||
# Identifier characters plus the sentinel, so an interpolated name is captured
|
||||
# rather than silently truncated to the literal prefix beside it.
|
||||
_SQL_IDENT = r'[A-Za-z0-9_$`"\[\]\.' + _SQL_HOLE + r']+'
|
||||
|
||||
# (op name, compiled pattern, number of table groups). Ordered only for
|
||||
# readability; every pattern is tried against every statement.
|
||||
_SQL_DDL_PATTERNS = (
|
||||
('execute:alter_table',
|
||||
re.compile(r'\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(' + _SQL_IDENT + r')', re.I)),
|
||||
('execute:drop_table',
|
||||
re.compile(r'\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(' + _SQL_IDENT + r')', re.I)),
|
||||
('execute:create_table',
|
||||
re.compile(r'\bCREATE\s+(?:TEMPORARY\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?('
|
||||
+ _SQL_IDENT + r')', re.I)),
|
||||
('execute:rename_table',
|
||||
re.compile(r'\bRENAME\s+TABLE\s+(' + _SQL_IDENT + r')\s+TO\s+('
|
||||
+ _SQL_IDENT + r')', re.I)),
|
||||
('execute:create_index',
|
||||
re.compile(r'\bCREATE\s+(?:UNIQUE\s+|FULLTEXT\s+|SPATIAL\s+)?INDEX\s+'
|
||||
+ _SQL_IDENT + r'\s+ON\s+(' + _SQL_IDENT + r')', re.I)),
|
||||
('execute:drop_index',
|
||||
re.compile(r'\bDROP\s+INDEX\s+' + _SQL_IDENT + r'\s+ON\s+('
|
||||
+ _SQL_IDENT + r')', re.I)),
|
||||
('execute:truncate_table',
|
||||
re.compile(r'\bTRUNCATE\s+(?:TABLE\s+)?(' + _SQL_IDENT + r')', re.I)),
|
||||
)
|
||||
|
||||
# Statement verbs that are data, not schema. A statement starting with one of
|
||||
# these is skipped outright, even if its body happens to contain a word like
|
||||
# "table"; anything starting with something unreadable is recorded UNRESOLVED,
|
||||
# because `op.execute(sql)` with sql built elsewhere is the same dodge as an
|
||||
# f-string.
|
||||
_SQL_DML_VERBS = ('select', 'insert', 'update', 'delete', 'replace', 'with',
|
||||
'set', 'commit', 'rollback', 'analyze', 'optimize', 'call',
|
||||
'pragma', 'flush', 'use', 'show', 'start')
|
||||
|
||||
# Historical lane crossings that have already shipped to production. Every entry
|
||||
# is closed - listed so the guard is green today and red for anything new, NOT
|
||||
# as a precedent to extend. Adding a line here means a plugin chain wrote to
|
||||
# somebody else's table, which needs an ADR amendment, not an allowlist edit.
|
||||
#
|
||||
# The key is (plugin, filename, op, table, column). The column is part of it on
|
||||
# purpose: keyed on the first four alone, the endpoints 0023 drop_column entry
|
||||
# would also excuse a brand new `op.drop_column('assets', 'assetnumber')`
|
||||
# appended to that same shipped file, which is exactly the breach this guard
|
||||
# exists to stop. `None` means the op names no single column (drop_index) or
|
||||
# names one the scan cannot resolve.
|
||||
#
|
||||
# endpoints 0023 is the CLEANUP of the 0021/0022 client-certificate breach: it
|
||||
# drops the five cert columns and any index over them from the core assets
|
||||
# table. It is applied on WJ production, so it cannot be edited - the fix is
|
||||
# that no 0025 may do the same thing.
|
||||
#
|
||||
# 0021 and 0022 themselves are NOT listed, and must not be: they were rewritten
|
||||
# to stamp-only no-ops, so they present no DDL to scan. Their damage lives on
|
||||
# only as the 0023 cleanup below.
|
||||
#
|
||||
# The drop_index half of that cleanup (0023:71-73) reads alarmingly: it drops
|
||||
# ANY index on assets whose column set INTERSECTS the cert columns, and never
|
||||
# recreates one. An index over (assetid, client_cert_fingerprint) is dead once
|
||||
# the column goes, but an index that merely happened to include a cert column
|
||||
# alongside columns that still matter would be silently lost. Settled here so
|
||||
# the next reader does not re-derive it - only these things ever create an index
|
||||
# on assets:
|
||||
# * core 68b3947ae14f baseline: idx_asset_active, idx_asset_location,
|
||||
# idx_asset_status, idx_asset_type_bu, ix_assets_assetnumber,
|
||||
# ix_assets_serialnumber
|
||||
# * core 7b02: ix_assets_gaugelabreference, ix_assets_maintenancereference
|
||||
# * core 7d33: idx_assets_levelid
|
||||
# * the shipped endpoints 0021: idx_assets_cert_fingerprint over
|
||||
# (client_cert_fingerprint) alone. The shipped 0022 added two more cert
|
||||
# columns and no index at all.
|
||||
# * db.create_all off shopdb/core/models/asset.py. Between 18e5fdc00
|
||||
# (2026-09-14) and 7480e4fe3 (2026-09-15) that model DID carry the five cert
|
||||
# columns, with client_cert_fingerprint declared index=True, so create_all
|
||||
# built ix_assets_client_cert_fingerprint on every database created in that
|
||||
# window. That is a second cert index, not just the one 0021 named, and the
|
||||
# inventory above is wrong without it.
|
||||
# It changes nothing for this loop. ix_assets_client_cert_fingerprint is
|
||||
# single-column over client_cert_fingerprint, which 0023 drops in the very next
|
||||
# statement, so the index carries no column that outlives it and there is
|
||||
# nothing to recreate. The conclusion holds: the only indexes the loop can drop
|
||||
# are idx_assets_cert_fingerprint and ix_assets_client_cert_fingerprint, both
|
||||
# dead by construction. No index was lost and no repair migration is owed.
|
||||
#
|
||||
# The filter is also unreachable by accident: every name in _CERT_COLUMNS is
|
||||
# underscore-spelled, which this repo's column convention forbids, so no real
|
||||
# assets column can collide with it.
|
||||
ALLOWED_CROSS_LANE_OPS = {
|
||||
('endpoints', '0023_endpoint_credentials.py', 'drop_index', 'assets', None),
|
||||
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
|
||||
'client_cert_fingerprint'),
|
||||
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
|
||||
'cert_issued_at'),
|
||||
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
|
||||
'cert_expires_at'),
|
||||
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
|
||||
'cert_revoked_at'),
|
||||
('endpoints', '0023_endpoint_credentials.py', 'drop_column', 'assets',
|
||||
'cert_revocation_reason'),
|
||||
}
|
||||
|
||||
|
||||
def _core_tablenames() -> set:
|
||||
"""Every table the CORE chain owns, read statically off shopdb/core/models.
|
||||
|
||||
Picks up both `__tablename__` on a model and the bare `db.Table(...)`
|
||||
association tables (userroles, rolepermissions, userpermissions), which have
|
||||
no model class and would otherwise read as unowned.
|
||||
"""
|
||||
names = set()
|
||||
for source in CORE_MODELS_DIR.glob('*.py'):
|
||||
tree = ast.parse(source.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
|
||||
if '__tablename__' in targets and isinstance(node.value, ast.Constant):
|
||||
names.add(node.value.value)
|
||||
if isinstance(node, ast.Call):
|
||||
func = node.func
|
||||
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, 'id', None)
|
||||
if name == 'Table' and node.args:
|
||||
first = node.args[0]
|
||||
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
||||
names.add(first.value)
|
||||
return names
|
||||
|
||||
|
||||
def _table_owner_map() -> dict:
|
||||
"""table name -> owning lane ('core' or a plugin name)."""
|
||||
owners = {}
|
||||
for plugin, tables in PLUGIN_TABLE_OWNERS.items():
|
||||
for tablename in tables:
|
||||
owners[tablename] = plugin
|
||||
for tablename in _core_tablenames():
|
||||
# A plugin entry wins: pre-cutover the core chain CREATED the plugin
|
||||
# tables, so a name can look core-shaped and still belong to a plugin.
|
||||
owners.setdefault(tablename, 'core')
|
||||
return owners
|
||||
|
||||
|
||||
def _module_constants(tree) -> dict:
|
||||
"""Module-level `NAME = 'sometable'` bindings.
|
||||
|
||||
Half the chains name their tables through constants (CREDENTIALS,
|
||||
REQUESTS, _APPS), so a scan that only understands string literals sees
|
||||
almost nothing.
|
||||
"""
|
||||
consts = {}
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant) \
|
||||
and isinstance(node.value.value, str):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
consts[target.id] = node.value.value
|
||||
return consts
|
||||
|
||||
|
||||
def _module_sequences(tree) -> dict:
|
||||
"""Module-level `NAME = ('a', 'b')` bindings of all-string sequences.
|
||||
|
||||
Needed for the column half of the allowlist key: endpoints 0023 drops its
|
||||
five columns with `for column in _CERT_COLUMNS`, and without this the column
|
||||
resolves to nothing and the allowlist entry degrades back to a blanket
|
||||
"drop_column on assets from this file is fine".
|
||||
"""
|
||||
seqs = {}
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
if not isinstance(node.value, (ast.Tuple, ast.List, ast.Set)):
|
||||
continue
|
||||
values = set()
|
||||
for element in node.value.elts:
|
||||
if not (isinstance(element, ast.Constant) and isinstance(element.value, str)):
|
||||
values = set()
|
||||
break
|
||||
values.add(element.value)
|
||||
if not values:
|
||||
continue
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
seqs[target.id] = frozenset(values)
|
||||
return seqs
|
||||
|
||||
|
||||
def _literal(node, consts):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
return consts.get(node.id)
|
||||
return None
|
||||
|
||||
|
||||
def _loop_bindings(tree, consts, seqs) -> dict:
|
||||
"""Map each Call node to the loop variables in scope over a literal sequence.
|
||||
|
||||
`for table in (IMPACTS, TRANSITIONS, HEALTHSTATES): op.drop_table(table)`
|
||||
(endpoints 0017) and the same shape in guides 0001 are real DDL over real
|
||||
tables. Without this they resolve to nothing, and "resolves to nothing" has
|
||||
to be a failure or the guard is dodgeable by writing a loop.
|
||||
|
||||
A bare module constant as the iterable (`for column in _CERT_COLUMNS`) is
|
||||
resolved through `seqs` for the same reason.
|
||||
"""
|
||||
bindings = {}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.For) or not isinstance(node.target, ast.Name):
|
||||
continue
|
||||
values = set()
|
||||
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
|
||||
for element in node.iter.elts:
|
||||
value = _literal(element, consts)
|
||||
if value is None:
|
||||
values = set()
|
||||
break
|
||||
values.add(value)
|
||||
elif isinstance(node.iter, ast.Name):
|
||||
values = set(seqs.get(node.iter.id, ()))
|
||||
if not values:
|
||||
continue
|
||||
# ast.walk is breadth-first from the top, so an inner loop rebinding the
|
||||
# same name is visited after the outer one and correctly shadows it.
|
||||
for sub in ast.walk(node):
|
||||
if isinstance(sub, ast.Call):
|
||||
bindings.setdefault(id(sub), {})[node.target.id] = frozenset(values)
|
||||
return bindings
|
||||
|
||||
|
||||
def _resolve_tables(node, consts, loopvars) -> frozenset:
|
||||
"""Every table name an argument could be. Empty means unresolvable."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return frozenset({node.value})
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id in loopvars:
|
||||
return loopvars[node.id]
|
||||
if node.id in consts:
|
||||
return frozenset({consts[node.id]})
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _resolve_columns(opname, node, args, consts, loopvars, positions) -> frozenset:
|
||||
"""The column name(s) an op names, or empty when it names none.
|
||||
|
||||
Empty is NOT a failure here the way it is for a table: most ops name no
|
||||
column at all. It only widens the allowlist key to None.
|
||||
"""
|
||||
position = positions.get(opname)
|
||||
if position is None or len(args) <= position:
|
||||
return frozenset()
|
||||
argument = args[position]
|
||||
if opname == 'add_column':
|
||||
# add_column takes sa.Column('name', ...), not a bare string.
|
||||
if isinstance(argument, ast.Call) and argument.args:
|
||||
argument = argument.args[0]
|
||||
else:
|
||||
return frozenset()
|
||||
return _resolve_tables(argument, consts, loopvars)
|
||||
|
||||
|
||||
def _flatten_sql(node, consts) -> str:
|
||||
"""Statement text with every unresolvable fragment replaced by a sentinel.
|
||||
|
||||
Keeping the hole in place (instead of giving up on the whole string) is what
|
||||
lets `f"ALTER TABLE equipment DROP FOREIGN KEY {name}"` resolve to
|
||||
`equipment` while `f'ALTER TABLE {t} DROP COLUMN x'` resolves to nothing.
|
||||
"""
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value if isinstance(node.value, str) else _SQL_HOLE
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
return ''.join(_flatten_sql(value, consts) for value in node.values)
|
||||
if isinstance(node, ast.FormattedValue):
|
||||
return _SQL_HOLE
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||
return _flatten_sql(node.left, consts) + _flatten_sql(node.right, consts)
|
||||
if isinstance(node, ast.Name):
|
||||
return consts.get(node.id, _SQL_HOLE)
|
||||
if isinstance(node, ast.Call):
|
||||
# sa.text('...') / text('...') is a wrapper, not a hole: computers 0006
|
||||
# passes its statement that way.
|
||||
func = node.func
|
||||
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, 'id', None)
|
||||
if name == 'text' and len(node.args) == 1:
|
||||
return _flatten_sql(node.args[0], consts)
|
||||
return _SQL_HOLE
|
||||
|
||||
|
||||
def _clean_sql_identifier(raw: str) -> str:
|
||||
"""`db`.`assets` -> assets. Keeps the sentinel if one landed in the name."""
|
||||
name = raw.strip().rstrip('(;,')
|
||||
if '.' in name:
|
||||
name = name.rsplit('.', 1)[-1]
|
||||
return name.strip('`"[]')
|
||||
|
||||
|
||||
def _scan_raw_sql(node, consts) -> list:
|
||||
"""(opname, frozenset-of-tables) for the DDL in one execute-style call.
|
||||
|
||||
An empty table set is UNRESOLVED and the caller fails on it. A statement
|
||||
that is plainly DML returns nothing at all.
|
||||
"""
|
||||
statement = _flatten_sql(node, consts)
|
||||
found = []
|
||||
# Classify PER STATEMENT, not per call. Reading the first verb and bailing
|
||||
# let a leading DML verb hide trailing DDL: op.execute("SET @x = 1; ALTER
|
||||
# TABLE assets DROP COLUMN serialnumber") was skipped whole, because the
|
||||
# scan saw SET, called it DML and returned nothing.
|
||||
for part in statement.split(';'):
|
||||
stripped = part.lstrip().lstrip('(')
|
||||
verb = stripped.split(None, 1)[0].lower() if stripped.split() else ''
|
||||
if not verb:
|
||||
continue
|
||||
if verb.startswith(_SQL_HOLE):
|
||||
# The verb itself is interpolated, so nothing about this statement
|
||||
# can be checked. Unresolved op rather than no op.
|
||||
found.append(('execute:unknown', frozenset()))
|
||||
continue
|
||||
if verb in _SQL_DML_VERBS:
|
||||
continue
|
||||
for opname, pattern in _SQL_DDL_PATTERNS:
|
||||
for match in pattern.finditer(part):
|
||||
for group in match.groups():
|
||||
name = _clean_sql_identifier(group)
|
||||
if not name or _SQL_HOLE in name:
|
||||
found.append((opname, frozenset()))
|
||||
else:
|
||||
found.append((opname, frozenset({name})))
|
||||
return found
|
||||
|
||||
|
||||
def _scan_plugin_migration_ops() -> list:
|
||||
"""Every schema op in every plugin chain, as
|
||||
(plugin, filename, lineno, opname, frozenset-of-tables,
|
||||
frozenset-of-columns).
|
||||
|
||||
An empty table set means the argument could not be resolved statically;
|
||||
the caller treats that as a failure rather than a pass. An empty column set
|
||||
only means the op names no resolvable single column.
|
||||
"""
|
||||
found = []
|
||||
for script in sorted(PLUGINS_DIR.glob('*/migrations/versions/*.py')):
|
||||
plugin = script.parts[-4]
|
||||
tree = ast.parse(script.read_text())
|
||||
consts = _module_constants(tree)
|
||||
seqs = _module_sequences(tree)
|
||||
loops = _loop_bindings(tree, consts, seqs)
|
||||
|
||||
# batch_alter_table('assets') moves the table onto the context manager,
|
||||
# so batch_op.drop_column('x') names a column where op.drop_column names
|
||||
# a table. Resolve those against the `with` instead.
|
||||
batched = {}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.With):
|
||||
continue
|
||||
for item in node.items:
|
||||
call = item.context_expr
|
||||
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute)
|
||||
and call.func.attr == 'batch_alter_table' and call.args):
|
||||
continue
|
||||
tables = _resolve_tables(call.args[0], consts, loops.get(id(call), {}))
|
||||
alias = item.optional_vars.id if isinstance(item.optional_vars, ast.Name) else None
|
||||
for sub in ast.walk(node):
|
||||
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute) \
|
||||
and sub.func.attr in _TABLE_ARG_POSITION \
|
||||
and isinstance(sub.func.value, ast.Name) \
|
||||
and sub.func.value.id == alias:
|
||||
batched[id(sub)] = tables
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
||||
continue
|
||||
opname = node.func.attr
|
||||
base = node.func.value
|
||||
|
||||
# Raw SQL: op.execute(...), bind.execute(...) and any
|
||||
# <connection>.exec_driver_sql(...).
|
||||
#
|
||||
# Both names are matched on the method name ALONE, without looking at
|
||||
# what they are called on. Requiring the receiver to be literally `op`
|
||||
# missed the repo's DOMINANT idiom: 24 call sites across endpoints
|
||||
# 0006/0007/0010/0019, network 0003 and notifications 0005 all do
|
||||
# `bind = op.get_bind()` and then `bind.execute(...)`, so a plugin
|
||||
# could drop a column off the core assets table through a bind and
|
||||
# the scan recorded nothing at all. Every shipped bind.execute passes
|
||||
# a literal SELECT or UPDATE, which _SQL_DML_VERBS skips, so widening
|
||||
# this costs no false positives.
|
||||
if opname in ('exec_driver_sql', 'execute'):
|
||||
if not node.args:
|
||||
continue
|
||||
for sqlop, tables in _scan_raw_sql(node.args[0], consts):
|
||||
found.append((plugin, script.name, node.lineno, sqlop,
|
||||
tables, frozenset()))
|
||||
continue
|
||||
|
||||
if opname not in _TABLE_ARG_POSITION:
|
||||
continue
|
||||
loopvars = loops.get(id(node), {})
|
||||
if id(node) in batched:
|
||||
columns = _resolve_columns(opname, node, node.args, consts, loopvars,
|
||||
_BATCH_COLUMN_ARG_POSITION)
|
||||
found.append((plugin, script.name, node.lineno,
|
||||
'batch_' + opname, batched[id(node)], columns))
|
||||
continue
|
||||
if not (isinstance(base, ast.Name) and base.id == 'op'):
|
||||
continue
|
||||
position = _TABLE_ARG_POSITION[opname]
|
||||
tables = frozenset()
|
||||
if len(node.args) > position:
|
||||
tables = _resolve_tables(node.args[position], consts, loopvars)
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg in _TABLE_KEYWORDS:
|
||||
tables = tables | _resolve_tables(keyword.value, consts, loopvars)
|
||||
columns = _resolve_columns(opname, node, node.args, consts, loopvars,
|
||||
_COLUMN_ARG_POSITION)
|
||||
found.append((plugin, script.name, node.lineno, opname, tables, columns))
|
||||
if opname == 'rename_table' and len(node.args) > 1:
|
||||
# Both ends of a rename are DDL on a table.
|
||||
found.append((plugin, script.name, node.lineno, 'rename_table',
|
||||
_resolve_tables(node.args[1], consts, loopvars),
|
||||
frozenset()))
|
||||
return found
|
||||
|
||||
|
||||
def _cross_lane_ops() -> list:
|
||||
"""Scanned ops whose table belongs to core or to a different plugin.
|
||||
|
||||
A table that is in neither the core models nor PLUGIN_TABLE_OWNERS is NOT a
|
||||
crossing: that is a pre-rename name (machines 0002 renames equipment ->
|
||||
machines) or a table since dropped, and neither reaches into another lane.
|
||||
Ownership drift of a table that DOES still exist is already covered by
|
||||
test_owned_tables_match_declared_models.
|
||||
"""
|
||||
owners = _table_owner_map()
|
||||
crossings = []
|
||||
for plugin, filename, lineno, opname, tables, columns in _scan_plugin_migration_ops():
|
||||
for tablename in sorted(tables):
|
||||
owner = owners.get(tablename)
|
||||
if owner is None or owner == plugin:
|
||||
continue
|
||||
for column in (sorted(columns) or [None]):
|
||||
crossings.append((plugin, filename, lineno, opname, tablename,
|
||||
owner, column))
|
||||
return crossings
|
||||
|
||||
|
||||
def _crossing_keys() -> set:
|
||||
return {(plugin, filename, opname, tablename, column)
|
||||
for plugin, filename, _, opname, tablename, _, column in _cross_lane_ops()}
|
||||
|
||||
|
||||
def test_plugin_migration_ops_all_resolve_to_a_table():
|
||||
"""Every scanned op names a table the scan can actually resolve.
|
||||
|
||||
Without this the guard is dodgeable by accident: a table name built at
|
||||
runtime resolves to nothing, and "nothing" would otherwise read as "touches
|
||||
no foreign table" and pass. It is also what makes the raw-SQL scan real -
|
||||
`op.execute(f'ALTER TABLE {t} ...')` lands here rather than going quietly
|
||||
green.
|
||||
"""
|
||||
unresolved = [(plugin, filename, lineno, opname)
|
||||
for plugin, filename, lineno, opname, tables, _
|
||||
in _scan_plugin_migration_ops() if not tables]
|
||||
assert not unresolved, (
|
||||
'plugin migration ops whose table name could not be resolved '
|
||||
f'statically: {unresolved}')
|
||||
|
||||
|
||||
def test_scan_sees_the_known_lane_crossing():
|
||||
"""The scanner is not vacuous: it still finds the 0023 assets cleanup.
|
||||
|
||||
If a refactor breaks the AST walk, every other assertion here goes green on
|
||||
an empty result set. This one goes red.
|
||||
"""
|
||||
scanned = _scan_plugin_migration_ops()
|
||||
assert len(scanned) > 100, f'scan found only {len(scanned)} ops; it is broken'
|
||||
keys = _crossing_keys()
|
||||
assert ALLOWED_CROSS_LANE_OPS <= keys, (
|
||||
'the allowlisted historical crossings are no longer detected: '
|
||||
f'{ALLOWED_CROSS_LANE_OPS - keys}')
|
||||
|
||||
|
||||
def test_scan_reads_raw_sql_ddl():
|
||||
"""The raw-SQL half is not vacuous either.
|
||||
|
||||
machines 0002 does its whole MySQL rename through exec_driver_sql. If the
|
||||
statement parser stops working, those ops vanish and nothing else in this
|
||||
file notices.
|
||||
"""
|
||||
raw = [(plugin, filename, opname, tuple(sorted(tables)))
|
||||
for plugin, filename, _, opname, tables, _
|
||||
in _scan_plugin_migration_ops() if opname.startswith('execute:')]
|
||||
assert ('machines', '0002_rename_from_equipment.py', 'execute:rename_table',
|
||||
('equipmenttypes',)) in raw, raw
|
||||
assert ('machines', '0002_rename_from_equipment.py', 'execute:alter_table',
|
||||
('machines',)) in raw, raw
|
||||
# DML stays out of it, or the three shipped UPDATE statements would read as
|
||||
# schema changes on tables their own plugin owns.
|
||||
assert not [entry for entry in raw
|
||||
if entry[0] == 'backups' or entry[1].startswith('0006_policy')], raw
|
||||
|
||||
|
||||
def test_no_plugin_migration_touches_another_lanes_table():
|
||||
"""ADR-008: a plugin chain does DDL only on the tables it owns.
|
||||
|
||||
Fails on create_table/drop_table/add_column/drop_column/alter_column/
|
||||
create_index/drop_index/rename_table, the five constraint ops, the
|
||||
batch_alter_table forms, and DDL inside op.execute/exec_driver_sql, against
|
||||
a core table or another plugin's table. The allowlist above holds exactly
|
||||
the entries that already shipped.
|
||||
"""
|
||||
offenders = [crossing for crossing in _cross_lane_ops()
|
||||
if (crossing[0], crossing[1], crossing[3], crossing[4], crossing[6])
|
||||
not in ALLOWED_CROSS_LANE_OPS]
|
||||
assert not offenders, '\n'.join(
|
||||
f'{plugin} chain: {filename}:{lineno} {opname} on {tablename!r}'
|
||||
+ (f' column {column!r}' if column else '')
|
||||
+ f', owned by {owner} (ADR-008)'
|
||||
for plugin, filename, lineno, opname, tablename, owner, column in offenders)
|
||||
|
||||
|
||||
def test_cross_lane_allowlist_has_no_dead_entries():
|
||||
"""The allowlist does not outlive what it excuses.
|
||||
|
||||
An entry left behind after its migration is squashed or rewritten is a
|
||||
standing licence to write to a core table from that file again.
|
||||
"""
|
||||
keys = _crossing_keys()
|
||||
assert not (ALLOWED_CROSS_LANE_OPS - keys), (
|
||||
'allowlist entries that match nothing in the tree: '
|
||||
f'{ALLOWED_CROSS_LANE_OPS - keys}')
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
The shared core Alembic baseline creates every plugin's tables, so a lean site
|
||||
that omits a plugin still carries that plugin's (empty) tables. prune-schema
|
||||
drops the tables of every not-installed plugin, leaving core + chosen-plugin
|
||||
tables. These tests pin the three behaviors that matter: only not-installed
|
||||
plugins are dropped, the non-empty guard refuses without --force, and a full
|
||||
site (every plugin installed) is a no-op.
|
||||
tables. These tests pin the behaviors that matter: only not-installed plugins
|
||||
are dropped, the non-empty guard refuses without --force, a full site (every
|
||||
plugin installed) is a no-op, and an ORPHAN_TABLE_OWNERS table - one the core
|
||||
chain creates for a plugin that no longer ships - is dropped too, because there
|
||||
is no plugin left to "install" that would ever spare it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -13,7 +15,8 @@ from sqlalchemy import inspect, text
|
||||
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.plugins.cli import plugin_cli
|
||||
from shopdb.plugins.alembic_template import PLUGIN_TABLE_OWNERS
|
||||
from shopdb.plugins.alembic_template import (
|
||||
ORPHAN_TABLE_OWNERS, PLUGIN_TABLE_OWNERS)
|
||||
|
||||
|
||||
def _install_only(app, monkeypatch, installed_names):
|
||||
@@ -28,7 +31,7 @@ def test_prune_drops_only_not_installed(app, db, runner, monkeypatch):
|
||||
"""A lean site keeps its plugins' tables and drops every other plugin's."""
|
||||
_install_only(app, monkeypatch, {'machines', 'printers'})
|
||||
before = set(inspect(_db.engine).get_table_names())
|
||||
assert 'usbdevices' in before and 'machines' in before # baseline sanity
|
||||
assert 'guides' in before and 'machines' in before # baseline sanity
|
||||
|
||||
result = runner.invoke(plugin_cli, ['prune-schema', '--yes'])
|
||||
assert result.exit_code == 0, result.output
|
||||
@@ -63,7 +66,7 @@ def test_prune_refuses_non_empty_without_force(app, db, runner, monkeypatch):
|
||||
"""A not-installed table that holds rows is not dropped without --force."""
|
||||
_install_only(app, monkeypatch, {'machines'})
|
||||
_db.session.execute(text(
|
||||
"INSERT INTO usbdevicetypes (typename, createddate, modifieddate, "
|
||||
"INSERT INTO printertypes (printertype, createddate, modifieddate, "
|
||||
"isactive) VALUES ('kept-by-guard', '2026-01-01', '2026-01-01', 1)"))
|
||||
_db.session.commit()
|
||||
|
||||
@@ -71,17 +74,37 @@ def test_prune_refuses_non_empty_without_force(app, db, runner, monkeypatch):
|
||||
assert result.exit_code == 1
|
||||
assert 'REFUSING' in result.output
|
||||
# The row-bearing table survives the refusal.
|
||||
assert 'usbdevicetypes' in set(inspect(_db.engine).get_table_names())
|
||||
assert 'printertypes' in set(inspect(_db.engine).get_table_names())
|
||||
|
||||
|
||||
def test_prune_force_drops_non_empty(app, db, runner, monkeypatch):
|
||||
"""--force drops even a table that holds rows."""
|
||||
_install_only(app, monkeypatch, {'machines'})
|
||||
_db.session.execute(text(
|
||||
"INSERT INTO usbdevicetypes (typename, createddate, modifieddate, "
|
||||
"INSERT INTO printertypes (printertype, createddate, modifieddate, "
|
||||
"isactive) VALUES ('dropped-by-force', '2026-01-01', '2026-01-01', 1)"))
|
||||
_db.session.commit()
|
||||
|
||||
result = runner.invoke(plugin_cli, ['prune-schema', '--yes', '--force'])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert 'usbdevicetypes' not in set(inspect(_db.engine).get_table_names())
|
||||
assert 'printertypes' not in set(inspect(_db.engine).get_table_names())
|
||||
|
||||
|
||||
def test_prune_drops_an_orphan_table_no_plugin_owns(app, db, runner, monkeypatch):
|
||||
"""A table the core chain creates for a plugin that no longer ships.
|
||||
|
||||
This is the case with no plugin to install, so nothing can ever spare it:
|
||||
the core chain builds it on every site and only prune-schema takes it away.
|
||||
Built here by hand because the test database is created from the MODELS, and
|
||||
by definition no model declares this table any more.
|
||||
"""
|
||||
_install_only(app, monkeypatch, set(PLUGIN_TABLE_OWNERS))
|
||||
orphan = ORPHAN_TABLE_OWNERS['employees'][0]
|
||||
_db.session.execute(text(f'CREATE TABLE {orphan} (sso INTEGER PRIMARY KEY)'))
|
||||
_db.session.commit()
|
||||
assert orphan in set(inspect(_db.engine).get_table_names())
|
||||
|
||||
result = runner.invoke(plugin_cli, ['prune-schema', '--yes'])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
assert orphan not in set(inspect(_db.engine).get_table_names())
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
a plugin's kiosk_enabled / kiosk_token settings 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
|
||||
@@ -30,6 +30,12 @@ EXEMPT = {
|
||||
# Written by the app itself when the agent key is minted, not typed.
|
||||
'plugin.endpoints.agentkeyprovisioned',
|
||||
'plugin.endpoints.envkeyfallback',
|
||||
# Edited on the endpoints Enrollment page through
|
||||
# PUT /api/endpoints/enrollment/settings, not the generic settings view:
|
||||
# turning the shared key off needs a confirm that shows how many PCs are
|
||||
# not enrolled yet, which a plain settings field cannot give.
|
||||
'plugin.endpoints.sharedkeyallowed',
|
||||
'plugin.endpoints.autoenroll',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -77,18 +77,6 @@ def _marker(db, assetnumber):
|
||||
return asset
|
||||
|
||||
|
||||
def _tool(db, assetnumber):
|
||||
"""A measuring tool the collector did NOT create."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
at = AssetType.query.filter_by(assettype='measuring_tool').first()
|
||||
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(MeasuringTool(assetid=asset.assetid))
|
||||
db.session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
def _controlled(pcname, label):
|
||||
"""Asset numbers this PC controls under a collector label."""
|
||||
pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
|
||||
@@ -244,35 +232,12 @@ def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
|
||||
assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']
|
||||
|
||||
|
||||
# ------------------------------------------------------------ measuring tools
|
||||
|
||||
def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
|
||||
"""One file, no device type in it: the pc-type decides which sync uses it."""
|
||||
_tool(db, 'MT-9001')
|
||||
resp = _report(client, collector_key, 'KEYENCE100',
|
||||
pctype='gea-shopfloor-keyence', deviceid='MT-9001')
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
|
||||
assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
|
||||
assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None
|
||||
|
||||
|
||||
def test_the_0120_field_name_still_works(client, db, rig, collector_key):
|
||||
"""measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
|
||||
_tool(db, 'MT-9002')
|
||||
resp = _report(client, collector_key, 'KEYENCE200',
|
||||
pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
|
||||
assert resp.status_code in (200, 201)
|
||||
assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']
|
||||
|
||||
|
||||
def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
|
||||
_tool(db, 'MT-9003')
|
||||
_tool(db, 'MT-9004')
|
||||
resp = _report(client, collector_key, 'KEYENCE300',
|
||||
pctype='gea-shopfloor-keyence',
|
||||
deviceid='MT-9003', measuringtoolid='MT-9004')
|
||||
assert resp.status_code in (200, 201)
|
||||
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']
|
||||
# Measuring tools had a section here. No measuringtools plugin ships on this
|
||||
# line, so the collector's guarded import fails and no instrument asset or
|
||||
# collector:measuringtool link is ever created - see the comment in
|
||||
# plugins/computers/plugin.py at that import. The DEVICE-ID handling the section
|
||||
# exercised is not metrology-specific and is covered by the part-marker cases
|
||||
# above and below.
|
||||
|
||||
|
||||
def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
|
||||
@@ -297,30 +262,3 @@ def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
|
||||
|
||||
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
|
||||
assert _controlled('FMARK200', 'collector:partmarker') == []
|
||||
|
||||
|
||||
def test_an_incumbent_under_another_collector_label_is_still_seen(
|
||||
client, db, rig, collector_key):
|
||||
"""On a CMM the instrument IS the bay, so the incumbent's link is the
|
||||
machine sync's row under collector:machine. Counting only our own label
|
||||
left two live holders of one instrument, each invisible to the other.
|
||||
"""
|
||||
_tool(db, 'CMM4')
|
||||
# A LIVE incumbent: it reported through the collector, so it has a computer
|
||||
# row and a recent check-in. A bare asset would correctly be treated as
|
||||
# yielded, since something that cannot report cannot be holding anything.
|
||||
_report(client, collector_key, 'WCMM100',
|
||||
pctype='gea-shopfloor-keyence', deviceid='CMM4')
|
||||
# Relabel its link to the machine sync's origin, which is the shape a CMM
|
||||
# really has: there the instrument IS the reported bay.
|
||||
pc = Asset.query.filter(Asset.assetnumber.ilike('WCMM100')).first()
|
||||
held = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).first()
|
||||
held.label = 'collector:machine'
|
||||
db.session.commit()
|
||||
|
||||
resp = _report(client, collector_key, 'WCMM200',
|
||||
pctype='gea-shopfloor-keyence', deviceid='CMM4')
|
||||
|
||||
assert _controlled('WCMM200', 'collector:measuringtool') == []
|
||||
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
|
||||
assert 'CMM4' in warnings
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
"""The collector ADOPTS an existing instrument instead of minting a twin.
|
||||
|
||||
Minting derived the asset number from the HOSTNAME, so a permanent instrument
|
||||
inherited the identity of whichever PC drove it that week. And because the
|
||||
collector keyed idempotency on its OWN label, it could not see a tool it had not
|
||||
created - on prod that left 43 legacy MT-#### tools shadowed by minted
|
||||
<HOST>-CMM twins, three records deep for some CMMs.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
|
||||
KEY = 'collector-adoption-key'
|
||||
|
||||
|
||||
@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 rig(db):
|
||||
"""Asset types, a controls relationship type, and nothing else."""
|
||||
for name in ('computer', 'measuring_tool'):
|
||||
if not AssetType.query.filter_by(assettype=name).first():
|
||||
db.session.add(AssetType(assettype=name))
|
||||
if not RelationshipType.query.filter_by(relationshiptype='controls').first():
|
||||
db.session.add(RelationshipType(relationshiptype='controls'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _tool(db, assetnumber):
|
||||
"""A measuring tool asset the collector did NOT create."""
|
||||
assettype = AssetType.query.filter_by(assettype='measuring_tool').first()
|
||||
asset = Asset(assetnumber=assetnumber, assettypeid=assettype.assettypeid)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(MeasuringTool(assetid=asset.assetid))
|
||||
db.session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
def _report(client, key, hostname, **extra):
|
||||
payload = {'hostname': hostname, 'pctype': 'gea-shopfloor-cmm'}
|
||||
payload.update(extra)
|
||||
return client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': key})
|
||||
|
||||
|
||||
def _tools_controlled(hostname):
|
||||
pc = Asset.query.filter_by(assetnumber=hostname).first()
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
toolids = {r.assetid for r in MeasuringTool.query.with_entities(MeasuringTool.assetid)}
|
||||
rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pc.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.isactive.is_(True)).all()
|
||||
return [Asset.query.get(r.targetassetid).assetnumber
|
||||
for r in rows if r.targetassetid in toolids]
|
||||
|
||||
|
||||
def test_a_named_instrument_is_adopted_not_minted(client, db, rig, collector_key):
|
||||
"""measuringtool-id.txt names the instrument; nothing new is created."""
|
||||
_tool(db, 'MT-6227')
|
||||
assert _report(client, collector_key, 'FB7FB1V3',
|
||||
measuringtoolid='MT-6227').status_code in (200, 201)
|
||||
|
||||
assert _tools_controlled('FB7FB1V3') == ['MT-6227']
|
||||
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
|
||||
|
||||
|
||||
def test_a_tool_already_controlled_is_adopted(client, db, rig, collector_key):
|
||||
"""The legacy MT-#### case: linked by the import, invisible to the collector.
|
||||
|
||||
The link must pre-date the first report - that is the real sequence, and it
|
||||
is the whole point. Reporting first would mint the twin before adoption ever
|
||||
had a chance, which is exactly the bug.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
tool = _tool(db, 'MT-6224')
|
||||
computertype = AssetType.query.filter_by(assettype='computer').first()
|
||||
pc = Asset(assetnumber='FJPX1GT3', assettypeid=computertype.assettypeid)
|
||||
db.session.add(pc)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=pc.assetid, hostname='FJPX1GT3'))
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pc.assetid, targetassetid=tool.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid, label=None))
|
||||
db.session.commit()
|
||||
|
||||
assert _report(client, collector_key, 'FJPX1GT3').status_code in (200, 201)
|
||||
assert _tools_controlled('FJPX1GT3') == ['MT-6224']
|
||||
assert Asset.query.filter_by(assetnumber='FJPX1GT3-CMM').first() is None
|
||||
|
||||
|
||||
def test_the_reported_bay_id_is_adopted(client, db, rig, collector_key):
|
||||
"""The CMM case: cmmid.txt already reports CMM4, which is a real asset."""
|
||||
_tool(db, 'CMM4')
|
||||
assert _report(client, collector_key, 'FB7FB1V3',
|
||||
machinenumber='CMM4').status_code in (200, 201)
|
||||
|
||||
assert 'CMM4' in _tools_controlled('FB7FB1V3')
|
||||
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
|
||||
|
||||
|
||||
def test_minting_still_happens_when_nothing_exists(client, db, rig, collector_key):
|
||||
"""A genuinely new instrument has no identity to adopt - last resort."""
|
||||
assert _report(client, collector_key, 'FNEWPC01').status_code in (200, 201)
|
||||
assert _tools_controlled('FNEWPC01') == ['FNEWPC01-CMM']
|
||||
|
||||
|
||||
def test_a_named_instrument_that_does_not_exist_warns_and_mints_nothing_wrong(
|
||||
client, db, rig, collector_key):
|
||||
"""A typo must not mint a phantom instrument nobody can account for."""
|
||||
resp = _report(client, collector_key, 'FTYPO001', measuringtoolid='MT-NOPE')
|
||||
assert resp.status_code in (200, 201)
|
||||
body = resp.get_json()['data']
|
||||
assert any('MT-NOPE' in w for w in body.get('warnings', []))
|
||||
|
||||
|
||||
def test_adoption_is_idempotent(client, db, rig, collector_key):
|
||||
_tool(db, 'MT-6331')
|
||||
for _ in range(3):
|
||||
_report(client, collector_key, 'F9LQSDB4', measuringtoolid='MT-6331')
|
||||
assert _tools_controlled('F9LQSDB4') == ['MT-6331']
|
||||
|
||||
|
||||
def test_a_named_instrument_supersedes_a_minted_twin(
|
||||
client, db, rig, collector_key):
|
||||
"""The migration case: the twin already exists when the file names the real
|
||||
one.
|
||||
|
||||
This is the state prod is actually in - 43 minted twins already linked - so
|
||||
adoption that only works on a clean PC fixes nothing. The twin's link has to
|
||||
be archived, or the PC reads as controlling two instruments.
|
||||
"""
|
||||
_tool(db, 'MT-6227')
|
||||
_report(client, collector_key, 'FB7FB1V3')
|
||||
assert _tools_controlled('FB7FB1V3') == ['FB7FB1V3-CMM']
|
||||
|
||||
_report(client, collector_key, 'FB7FB1V3', measuringtoolid='MT-6227')
|
||||
assert _tools_controlled('FB7FB1V3') == ['MT-6227']
|
||||
|
||||
|
||||
def test_a_named_id_that_is_not_a_measuring_tool_is_refused(
|
||||
client, db, rig, collector_key):
|
||||
"""A machine number in measuringtool-id.txt must not link the machine.
|
||||
|
||||
The name resolving is not enough: linked under the measuring-tool label it
|
||||
would read as an instrument everywhere downstream, on a row the machine sync
|
||||
also owns.
|
||||
"""
|
||||
machinetype = AssetType.query.filter_by(assettype='machine').first()
|
||||
if machinetype is None:
|
||||
machinetype = AssetType(assettype='machine')
|
||||
db.session.add(machinetype)
|
||||
db.session.flush()
|
||||
db.session.add(Asset(assetnumber='2335', assettypeid=machinetype.assettypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = _report(client, collector_key, 'FTYPO777', measuringtoolid='2335')
|
||||
assert resp.status_code in (200, 201)
|
||||
body = resp.get_json()['data']
|
||||
assert any('2335' in w for w in body.get('warnings', []))
|
||||
assert '2335' not in _all_controlled('FTYPO777')
|
||||
|
||||
|
||||
def test_a_cmm_reporting_its_bay_survives_a_second_cycle(
|
||||
client, db, rig, collector_key):
|
||||
"""The instrument IS the reported bay, so both syncs want the same triple.
|
||||
|
||||
Adoption used to relabel the machine sync's row. The machine sync finds its
|
||||
rows BY that label, so cycle 2 built a second row for the same
|
||||
(pc, tool, controls) triple and MySQL rejected it on the unique constraint:
|
||||
200, then 500 on every report after. The machine link must survive as a
|
||||
machine link.
|
||||
"""
|
||||
_tool(db, 'CMM4')
|
||||
for _ in range(3):
|
||||
resp = _report(client, collector_key, 'FB7FB1V3', machinenumber='CMM4')
|
||||
assert resp.status_code in (200, 201)
|
||||
|
||||
assert _all_controlled('FB7FB1V3') == ['CMM4']
|
||||
assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None
|
||||
|
||||
pc = Asset.query.filter_by(assetnumber='FB7FB1V3').first()
|
||||
tool = Asset.query.filter_by(assetnumber='CMM4').first()
|
||||
rows = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, targetassetid=tool.assetid).all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].label == 'collector:machine'
|
||||
|
||||
|
||||
def test_a_reported_identifier_is_not_a_wildcard(client, db, rig, collector_key):
|
||||
"""`_` and `%` in a reported identifier must match themselves, nothing else.
|
||||
|
||||
These values come from a text file on a shopfloor PC. Resolved with ilike,
|
||||
'MT-600_' matched MT-6001 and a bare '%' matched whatever active asset came
|
||||
first - an identifier resolving to an asset it does not name, then linked to
|
||||
as though it did.
|
||||
"""
|
||||
_tool(db, 'MT-6001')
|
||||
resp = _report(client, collector_key, 'FWILD001', measuringtoolid='MT-600_')
|
||||
assert resp.status_code in (200, 201)
|
||||
assert 'MT-6001' not in _tools_controlled('FWILD001')
|
||||
|
||||
resp = _report(client, collector_key, 'FWILD002', measuringtoolid='%')
|
||||
assert resp.status_code in (200, 201)
|
||||
assert 'MT-6001' not in _tools_controlled('FWILD002')
|
||||
|
||||
|
||||
def _all_controlled(hostname):
|
||||
"""Every active controls target, tool or not - the refusal cases need to see
|
||||
a link the tool-only helper would filter out."""
|
||||
pc = Asset.query.filter_by(assetnumber=hostname).first()
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pc.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.isactive.is_(True)).all()
|
||||
targets = [r.targetassetid for r in rows]
|
||||
if not targets:
|
||||
return []
|
||||
return sorted(a.assetnumber for a in
|
||||
Asset.query.filter(Asset.assetid.in_(targets)).all())
|
||||
@@ -6,7 +6,7 @@ hand or by the legacy import was invisible and the insert violated the
|
||||
constraint; and the minted marker's asset number is derived from the PC, so it
|
||||
could already be taken. Either way the PC reported 200 on the cycle that minted
|
||||
the marker and 500 on every cycle after - the same shape as the CMM adoption bug
|
||||
in test_collector_measuringtool_adoption.py.
|
||||
in the collector contract tests.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
87
tests/test_plugins/test_computers_policy_allpcs.py
Normal file
87
tests/test_plugins/test_computers_policy_allpcs.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Scope evaluation for the All PCs policy target.
|
||||
|
||||
These are pure unit tests: policyscope has no db or Flask dependency, so the
|
||||
whole grammar can be exercised with plain dicts. build_endpoint_context needs
|
||||
only duck-typed stand-ins for a Computer row.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from plugins.computers.services.policyresolver import build_endpoint_context
|
||||
from plugins.computers.services.policyscope import (
|
||||
ScopeError,
|
||||
evaluate_scope,
|
||||
validate_scope,
|
||||
)
|
||||
|
||||
|
||||
class FakeComputerType:
|
||||
def __init__(self, icon):
|
||||
self.icon = icon
|
||||
|
||||
|
||||
class FakeComputer:
|
||||
"""Minimal stand-in for a Computer row, no db required."""
|
||||
|
||||
def __init__(self, computertypeid=None, icon=None, installedapps=()):
|
||||
self.computertypeid = computertypeid
|
||||
self.computertype = FakeComputerType(icon) if icon is not None else None
|
||||
self.installedapps = installedapps
|
||||
|
||||
|
||||
def test_allpcs_is_a_valid_scope_node():
|
||||
validate_scope({'type': 'allpcs'})
|
||||
|
||||
|
||||
def test_allpcs_matches_an_ordinary_pc():
|
||||
context = {'computertypeid': 1, 'appids': set(), 'isserver': False}
|
||||
assert evaluate_scope({'type': 'allpcs'}, context) is True
|
||||
|
||||
|
||||
def test_allpcs_does_not_match_a_server():
|
||||
"""The whole point of the option: servers are excluded."""
|
||||
context = {'computertypeid': 4, 'appids': set(), 'isserver': True}
|
||||
assert evaluate_scope({'type': 'allpcs'}, context) is False
|
||||
|
||||
|
||||
def test_allpcs_matches_an_endpoint_with_no_type_designation():
|
||||
"""An untyped endpoint is an ordinary PC until something says otherwise."""
|
||||
context = {'computertypeid': None, 'appids': set()}
|
||||
assert evaluate_scope({'type': 'allpcs'}, context) is True
|
||||
|
||||
|
||||
def test_true_still_matches_a_server():
|
||||
"""'true' keeps its old meaning so existing policies do not silently shift."""
|
||||
context = {'computertypeid': 4, 'appids': set(), 'isserver': True}
|
||||
assert evaluate_scope({'type': 'true'}, context) is True
|
||||
|
||||
|
||||
def test_allpcs_composes_with_other_nodes():
|
||||
"""AND of allpcs and an app still excludes a server running that app."""
|
||||
scope = {
|
||||
'type': 'and',
|
||||
'nodes': [{'type': 'allpcs'}, {'type': 'app', 'id': 7}],
|
||||
}
|
||||
pc = {'computertypeid': 1, 'appids': {7}, 'isserver': False}
|
||||
server = {'computertypeid': 4, 'appids': {7}, 'isserver': True}
|
||||
assert evaluate_scope(scope, pc) is True
|
||||
assert evaluate_scope(scope, server) is False
|
||||
|
||||
|
||||
def test_context_marks_a_server_icon_type_as_a_server():
|
||||
computer = FakeComputer(computertypeid=4, icon='server')
|
||||
assert build_endpoint_context(computer)['isserver'] is True
|
||||
|
||||
|
||||
def test_context_does_not_mark_a_desktop_type_as_a_server():
|
||||
computer = FakeComputer(computertypeid=1, icon='desktop')
|
||||
assert build_endpoint_context(computer)['isserver'] is False
|
||||
|
||||
|
||||
def test_context_handles_a_computer_with_no_type():
|
||||
computer = FakeComputer()
|
||||
assert build_endpoint_context(computer)['isserver'] is False
|
||||
|
||||
|
||||
def test_an_unknown_node_type_is_still_rejected():
|
||||
with pytest.raises(ScopeError):
|
||||
validate_scope({'type': 'allpc'})
|
||||
181
tests/test_plugins/test_computers_policy_authoring_gate.py
Normal file
181
tests/test_plugins/test_computers_policy_authoring_gate.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Authoring a policy requires the permission to run what the policy carries.
|
||||
|
||||
A policy is a deferred instruction to run something. Gating only the enqueue
|
||||
route (plugins/endpoints/api/routes.py) left authoring wide open: computers.edit
|
||||
alone could author a policy carrying run_script and let the agent execute it on
|
||||
every machine in scope, which undoes the whole point of splitting the big four.
|
||||
|
||||
These tests pin the four ways a policy's EFFECT can change - authoring actions,
|
||||
widening scope, reactivating, and composing - so a future edit cannot quietly
|
||||
reopen any one of them.
|
||||
"""
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
CONFIG_URL = '/api/computers/policies/configuration'
|
||||
BUSINESS_URL = '/api/computers/policies/business'
|
||||
|
||||
|
||||
def _enable_policies(db):
|
||||
if not Setting.query.filter_by(key='computers_configpolicies_enabled').first():
|
||||
db.session.add(Setting(key='computers_configpolicies_enabled',
|
||||
value='true'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _user_with_perms(client, db, username, permnames):
|
||||
"""A non-admin holding exactly permnames. Returns login headers."""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
Permission.seed_entries([
|
||||
('endpoints.runscript', 'Push scripts', 'endpoints'),
|
||||
('endpoints.restart', 'Remote restart', 'endpoints'),
|
||||
])
|
||||
db.session.flush()
|
||||
role = Role(rolename=username + 'role')
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
role.permissions = Permission.query.filter(
|
||||
Permission.name.in_(permnames)).all()
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = login.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _policy_editor(client, db, username='policyeditor', extra=()):
|
||||
return _user_with_perms(
|
||||
client, db, username,
|
||||
['computers.view', 'computers.create', 'computers.edit',
|
||||
'computers.delete', *extra])
|
||||
|
||||
|
||||
def _make_policy(db, name, action='run_script', scope=None, isactive=True):
|
||||
from plugins.computers.models import (ConfigurationPolicy,
|
||||
ConfigurationPolicyAction)
|
||||
policy = ConfigurationPolicy(name=name, scopejson=scope, isactive=isactive)
|
||||
db.session.add(policy)
|
||||
db.session.flush()
|
||||
db.session.add(ConfigurationPolicyAction(
|
||||
configurationpolicyid=policy.configurationpolicyid, action=action))
|
||||
db.session.commit()
|
||||
return policy.configurationpolicyid
|
||||
|
||||
|
||||
def test_authoring_run_script_needs_the_runscript_permission(client, db):
|
||||
"""The core escalation: computers.create alone must not mint a policy that
|
||||
runs arbitrary scripts."""
|
||||
_enable_policies(db)
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.post(CONFIG_URL, headers=headers, json={
|
||||
'name': 'Sneaky', 'actions': [{'action': 'run_script'}],
|
||||
})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'endpoints.runscript' in message
|
||||
|
||||
|
||||
def test_authoring_run_script_is_allowed_with_the_permission(client, db):
|
||||
"""Holding the grant makes it ordinary work, not a special case."""
|
||||
_enable_policies(db)
|
||||
headers = _policy_editor(client, db, extra=['endpoints.runscript'])
|
||||
|
||||
response = client.post(CONFIG_URL, headers=headers, json={
|
||||
'name': 'Legitimate', 'actions': [{'action': 'run_script'}],
|
||||
})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_widening_scope_on_an_existing_policy_is_gated(client, db):
|
||||
"""Scope widening changes what an already-authored action DOES without
|
||||
touching the action list. Gating the payload alone would miss it."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Narrow', scope={'type': 'pctype', 'id': 1})
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.put(f'{CONFIG_URL}/{policyid}', headers=headers,
|
||||
json={'scopejson': None})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
assert 'endpoints.runscript' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_renaming_a_policy_is_not_gated(client, db):
|
||||
"""A rename changes nothing that runs. Gating it would be security theatre
|
||||
that stops people tidying up."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Old Name')
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.put(f'{CONFIG_URL}/{policyid}', headers=headers,
|
||||
json={'name': 'New Name',
|
||||
'description': 'clearer wording'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
|
||||
def test_reactivating_a_policy_is_gated_but_deactivating_is_not(client, db):
|
||||
"""Reactivation re-arms the action. Deactivation cannot widen anything, and
|
||||
refusing it would strand a live policy nobody present can switch off."""
|
||||
_enable_policies(db)
|
||||
dormant = _make_policy(db, 'Dormant', isactive=False)
|
||||
live = _make_policy(db, 'Live', isactive=True)
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
blocked = client.put(f'{CONFIG_URL}/{dormant}', headers=headers,
|
||||
json={'isactive': True})
|
||||
assert blocked.status_code == 403, blocked.get_json()
|
||||
|
||||
allowed = client.put(f'{CONFIG_URL}/{live}', headers=headers,
|
||||
json={'isactive': False})
|
||||
assert allowed.status_code == 200, allowed.get_json()
|
||||
|
||||
|
||||
def test_composing_a_privileged_policy_into_a_business_policy_is_gated(
|
||||
client, db):
|
||||
"""Composition is the quiet escalation: attaching a configuration policy
|
||||
runs its actions without the composer ever naming them."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Privileged')
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.post(BUSINESS_URL, headers=headers, json={
|
||||
'name': 'Wrapper',
|
||||
'items': [{'configurationpolicyid': policyid}],
|
||||
})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
assert 'endpoints.runscript' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_deleting_a_policy_carrying_an_elevated_action_is_gated(client, db):
|
||||
"""Deleting a gate policy REMOVES a restriction, so it changes what
|
||||
endpoints will run just as surely as authoring one does."""
|
||||
_enable_policies(db)
|
||||
policyid = _make_policy(db, 'Restriction')
|
||||
headers = _policy_editor(client, db)
|
||||
|
||||
response = client.delete(f'{CONFIG_URL}/{policyid}', headers=headers)
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
|
||||
def test_the_action_catalog_marks_what_the_caller_may_author(client, db):
|
||||
"""Marked, not hidden. Hiding would make the editor look like the capability
|
||||
does not exist; marking says it exists and is not yours."""
|
||||
_enable_policies(db)
|
||||
headers = _policy_editor(client, db, extra=['endpoints.restart'])
|
||||
|
||||
response = client.get('/api/computers/policies/actions', headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
catalog = {row['action']: row for row in response.get_json()['data']}
|
||||
|
||||
assert catalog['system_restart']['mayauthor'] is True
|
||||
assert catalog['run_script']['mayauthor'] is False
|
||||
assert catalog['run_script']['requiredpermission'] == 'endpoints.runscript'
|
||||
@@ -1,212 +0,0 @@
|
||||
"""Tests for self-hosted employee photo upload, replace, delete, serve, and the
|
||||
shared photo-URL resolver used by both EmployeeDetail and the kiosk cards.
|
||||
|
||||
Covers: admin-gated upload/delete, the public serve route, one-photo-per-person
|
||||
replace semantics, external-mode 409s, and resolve_employee_photo_url in both
|
||||
directory modes. The wjf_employees HR DB is not available under test, so external
|
||||
resolution is exercised by passing the Picture value directly (no query)."""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
from plugins.employees.api.routes import (
|
||||
resolve_employee_photo_url,
|
||||
EMPLOYEE_PHOTO_URL_PREFIX,
|
||||
EMPLOYEE_PHOTO_STATIC_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def _set_mode(db, mode):
|
||||
row = Setting.query.filter_by(key='employee_directory_mode').first()
|
||||
if row:
|
||||
row.value = mode
|
||||
else:
|
||||
db.session.add(Setting(key='employee_directory_mode', value=mode,
|
||||
valuetype='string', category='site'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _make_employee(db, sso=210000001):
|
||||
emp = DirectoryEmployee(sso=sso, firstname='Test', lastname='Person')
|
||||
db.session.add(emp)
|
||||
db.session.commit()
|
||||
return emp
|
||||
|
||||
|
||||
# --- upload / replace / delete (self-hosted) --------------------------------
|
||||
|
||||
def test_upload_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot upload an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()['data']['error']['code'] == 'FORBIDDEN'
|
||||
|
||||
|
||||
def test_upload_sets_photofilename_and_file_exists(client, db, auth_headers, app):
|
||||
"""Admin upload writes photofilename + photourl and lands the file on disk."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nfake-png'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
|
||||
filename = f'photo-{emp.sso}.png'
|
||||
body = resp.get_json()['data']
|
||||
assert body['photofilename'] == filename
|
||||
assert body['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}{filename}'
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == filename
|
||||
|
||||
path = os.path.join(app.instance_path, 'employeephotos', filename)
|
||||
assert os.path.exists(path)
|
||||
with open(path, 'rb') as handle:
|
||||
assert handle.read() == payload
|
||||
|
||||
|
||||
def test_upload_rejects_invalid_extension(client, db, auth_headers):
|
||||
"""A disallowed file extension is rejected."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'MZ'), 'p.exe')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
|
||||
|
||||
|
||||
def test_upload_missing_employee_is_404(client, db, auth_headers):
|
||||
"""Uploading to a nonexistent SSO is a 404."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/999999/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_reupload_replaces_and_removes_old_extension(client, db, auth_headers, app):
|
||||
"""Re-upload with a different extension deletes the prior file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
first = {'file': (io.BytesIO(b'first'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=first,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
oldpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(oldpath)
|
||||
|
||||
second = {'file': (io.BytesIO(b'second'), 'p.jpg')}
|
||||
resp = client.post(f'/api/employees/{emp.sso}/photo', data=second,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert not os.path.exists(oldpath)
|
||||
newpath = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.jpg')
|
||||
assert os.path.exists(newpath)
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename == f'photo-{emp.sso}.jpg'
|
||||
|
||||
|
||||
def test_serve_returns_bytes(client, db, auth_headers):
|
||||
"""The public serve route returns the uploaded bytes without auth."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
payload = b'\x89PNG\r\n\x1a\nserved'
|
||||
data = {'file': (io.BytesIO(payload), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png', headers=auth_headers)
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
def test_delete_clears_field_and_removes_file(client, db, auth_headers, app):
|
||||
"""Delete clears photofilename and removes the uploaded file."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'bytes'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = os.path.join(app.instance_path, 'employeephotos', f'photo-{emp.sso}.png')
|
||||
assert os.path.exists(path)
|
||||
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] is None
|
||||
assert not os.path.exists(path)
|
||||
|
||||
refreshed = db.session.get(DirectoryEmployee, emp.sso)
|
||||
assert refreshed.photofilename is None
|
||||
|
||||
|
||||
def test_delete_forbidden_for_non_admin(client, db, member_headers):
|
||||
"""A role-less authenticated user cannot delete an employee photo."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
resp = client.delete(f'/api/employees/{emp.sso}/photo', headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# --- external-mode 409 ------------------------------------------------------
|
||||
|
||||
def test_upload_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Upload is a 409 when the directory is external (HR owns the photo)."""
|
||||
_set_mode(db, 'external')
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
resp = client.post('/api/employees/210000001/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
assert resp.get_json()['data']['error']['code'] == 'CONFLICT'
|
||||
|
||||
|
||||
def test_delete_conflict_in_external_mode(client, db, auth_headers):
|
||||
"""Delete is a 409 when the directory is external."""
|
||||
_set_mode(db, 'external')
|
||||
resp = client.delete('/api/employees/210000001/photo', headers=auth_headers)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
# --- resolver ---------------------------------------------------------------
|
||||
|
||||
def test_resolver_selfhosted_returns_upload_url_or_none(client, db, auth_headers):
|
||||
"""Self-hosted: served upload URL when a photo exists, else None."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
assert resolve_employee_photo_url(emp.sso) is None
|
||||
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
assert resolve_employee_photo_url(emp.sso) == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
|
||||
|
||||
def test_resolver_external_prefixes_relative_and_passes_urls(db):
|
||||
"""External: relative HR paths get the static prefix; absolute URLs pass through."""
|
||||
_set_mode(db, 'external')
|
||||
assert resolve_employee_photo_url(210000001, 'Support/210000001.png') == \
|
||||
f'{EMPLOYEE_PHOTO_STATIC_PREFIX}Support/210000001.png'
|
||||
assert resolve_employee_photo_url(210000001, 'https://hr.example.net/p.png') == \
|
||||
'https://hr.example.net/p.png'
|
||||
assert resolve_employee_photo_url(210000001, None) is None
|
||||
assert resolve_employee_photo_url(None) is None
|
||||
|
||||
|
||||
def test_lookup_includes_photourl(client, db, auth_headers):
|
||||
"""The lookup serializer carries the resolved photourl (self-hosted)."""
|
||||
_set_mode(db, 'selfhosted')
|
||||
emp = _make_employee(db)
|
||||
data = {'file': (io.BytesIO(b'x'), 'p.png')}
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
resp = client.get(f'/api/employees/lookup/{emp.sso}', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Resolving an SSO says HOW it resolved, or why it did not.
|
||||
|
||||
Built after the shopfloor board lost every photo and showed lowercase SSOs
|
||||
instead of names, and nothing in the system could say why. Both resolvers
|
||||
returned None on any problem, so an unreachable HR host, a rotated credential
|
||||
and a genuinely unknown SSO were indistinguishable - and none of them were
|
||||
logged. This endpoint exists to tell those apart.
|
||||
"""
|
||||
|
||||
URL = '/api/employees/resolve/'
|
||||
|
||||
|
||||
def _selfhosted(db):
|
||||
from shopdb.core.models import Setting
|
||||
row = Setting.query.filter_by(key='employee_directory_mode').first()
|
||||
if row:
|
||||
row.value = 'selfhosted'
|
||||
else:
|
||||
db.session.add(Setting(key='employee_directory_mode', value='selfhosted'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_a_known_employee_resolves_and_names_its_source(client, db, auth_headers):
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
_selfhosted(db)
|
||||
db.session.add(DirectoryEmployee(sso=502123, firstname='Ada',
|
||||
lastname='Lovelace'))
|
||||
db.session.commit()
|
||||
|
||||
data = client.get(URL + '502123', headers=auth_headers).get_json()['data']
|
||||
assert data['name'] == 'Ada Lovelace'
|
||||
assert data['source'] == 'directory'
|
||||
assert data['mode'] == 'selfhosted'
|
||||
assert data['error'] is None
|
||||
|
||||
|
||||
def test_an_unknown_sso_says_so_rather_than_returning_nothing(client, db,
|
||||
auth_headers):
|
||||
"""The board shows a lowercase SSO for this case AND for a broken
|
||||
directory. The whole point is that they now read differently."""
|
||||
_selfhosted(db)
|
||||
data = client.get(URL + '999999', headers=auth_headers).get_json()['data']
|
||||
assert data['name'] is None
|
||||
assert data['error']
|
||||
|
||||
|
||||
def test_a_user_account_answers_when_the_directory_does_not(client, db,
|
||||
auth_headers):
|
||||
"""Someone from another site has no directory row but does have a login.
|
||||
That fallback exists; without this endpoint nothing showed it had fired."""
|
||||
from shopdb.core.models import User
|
||||
_selfhosted(db)
|
||||
db.session.add(User(username='701234', firstname='Grace', lastname='Hopper',
|
||||
email='g@example.com', passwordhash='x'))
|
||||
db.session.commit()
|
||||
|
||||
data = client.get(URL + '701234', headers=auth_headers).get_json()['data']
|
||||
assert data['name'] == 'Grace Hopper'
|
||||
assert data['source'] == 'useraccount'
|
||||
|
||||
|
||||
def test_a_non_numeric_sso_is_rejected_clearly(client, db, auth_headers):
|
||||
data = client.get(URL + 'abc', headers=auth_headers).get_json()['data']
|
||||
assert data['error'] == 'SSO must be digits'
|
||||
|
||||
|
||||
def test_the_endpoint_needs_a_login(client, db):
|
||||
assert client.get(URL + '502123').status_code == 401
|
||||
250
tests/test_plugins/test_endpoints_agent_registration.py
Normal file
250
tests/test_plugins/test_endpoints_agent_registration.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Auto-registration of staged agent builds as a pushable Application.
|
||||
|
||||
The endpoint update_app push resolves an install source from the applications
|
||||
table alone; it cannot see the agent version store. Before this, a build could
|
||||
be staged and current while no application row existed at all, which is exactly
|
||||
what was found in the fleet - the agent was unpushable.
|
||||
|
||||
These tests pin the properties that make the bridge trustworthy:
|
||||
- staging creates the row, and re-staging updates it in place (never a row
|
||||
per build, because appname is unique and drift is the failure mode);
|
||||
- the packaged archive is byte-stable for unchanged input, because update_app
|
||||
pins a download to a digest and a drifting hash breaks verification;
|
||||
- every path that changes 'current' re-registers, including rollback;
|
||||
- registration failure never fails an upload that already landed on disk.
|
||||
|
||||
Uploads use PE-magic bytes with a monkeypatched version parser, matching the
|
||||
approach in test_endpoints_agentupload.py - building a real PE with an
|
||||
RT_VERSION resource inline is impractical.
|
||||
|
||||
Every test runs against an ISOLATED store. create_app('testing') resolves
|
||||
instance_path to the real repo instance/ directory, so without redirection
|
||||
these tests stage fake 130-byte binaries into the developer's actual agent
|
||||
store and leave invented versions behind in its manifest. The isolate fixture
|
||||
below is autouse for that reason: it is a correctness requirement, not a
|
||||
convenience.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.endpoints.api import agentstore, agentupload, agentregistry
|
||||
from shopdb.core.models.application import Application
|
||||
|
||||
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
|
||||
CURRENT_URL = '/api/endpoints/siteagent/binaries/{}/current'
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 128
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_store(tmp_path, monkeypatch):
|
||||
"""Point the agent store and package output at a per-test directory.
|
||||
|
||||
Both must be redirected. Redirecting only the store would still write the
|
||||
~zip into the real applicationpackages tree under a live application id.
|
||||
"""
|
||||
siteagent = tmp_path / 'siteagent'
|
||||
packages = tmp_path / 'applicationpackages'
|
||||
siteagent.mkdir()
|
||||
packages.mkdir()
|
||||
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
|
||||
monkeypatch.setattr(agentregistry, 'application_package_location',
|
||||
lambda: (str(packages), '/api/applications/package/'))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _upload(client, headers, monkeypatch, version, binkey='agent',
|
||||
payload=None, makecurrent=None):
|
||||
"""Stage a build through the real HTTP route."""
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version', lambda data: version)
|
||||
form = {'file': (io.BytesIO(payload or _PE_BYTES), 'shopdbagent.exe'),
|
||||
'version': version}
|
||||
if makecurrent is not None:
|
||||
form['makecurrent'] = makecurrent
|
||||
return client.post(UPLOAD_URL.format(binkey), data=form,
|
||||
content_type='multipart/form-data', headers=headers)
|
||||
|
||||
|
||||
def _agent_row():
|
||||
return Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).first()
|
||||
|
||||
|
||||
def test_staging_a_build_creates_a_pushable_application_row(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""Without this row, update_app has nothing to resolve and cannot push."""
|
||||
assert _agent_row() is None
|
||||
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
assert response.status_code == 200
|
||||
|
||||
registration = response.get_json()['data']['registration']
|
||||
assert registration['ok'] is True
|
||||
|
||||
row = _agent_row()
|
||||
assert row is not None
|
||||
# The row must be resolvable and pinned: a path update_app can fetch, and a
|
||||
# digest it can verify the bytes against.
|
||||
assert row.installpath == '/api/applications/package/{}/{}'.format(
|
||||
row.appid, agentregistry.AGENT_PACKAGE_FILENAME)
|
||||
assert row.installersha256 == registration['sha256']
|
||||
assert row.isinstallable is True
|
||||
# Hidden: this is machine-managed, and must not appear in the software
|
||||
# catalogue beside real licensed applications.
|
||||
assert row.ishidden is True
|
||||
|
||||
|
||||
def test_restaging_updates_the_same_row_rather_than_adding_one(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""appname is unique; a row per build would break the push target."""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
first = _agent_row()
|
||||
firstid = first.appid
|
||||
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.17.0.0')
|
||||
assert response.status_code == 200
|
||||
|
||||
assert Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).count() == 1
|
||||
row = _agent_row()
|
||||
assert row.appid == firstid
|
||||
# The row must follow the newly current build, not keep pointing at 1.16.
|
||||
assert row.installersha256 == \
|
||||
response.get_json()['data']['registration']['sha256']
|
||||
|
||||
|
||||
def test_packaging_the_same_build_twice_yields_the_same_digest(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""update_app pins bytes by digest.
|
||||
|
||||
zipfile stamps members with the current mtime by default, which made the
|
||||
archive hash change on every call for identical input. That would rewrite
|
||||
installersha256 out from under an endpoint mid-download and fail its
|
||||
SHA-256 verification.
|
||||
"""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
|
||||
firstpayload, firstmanifest = agentstore.build_current_package()
|
||||
secondpayload, secondmanifest = agentstore.build_current_package()
|
||||
|
||||
assert firstmanifest['sha256'] == secondmanifest['sha256']
|
||||
assert firstpayload == secondpayload
|
||||
|
||||
|
||||
def test_the_package_contains_the_staged_binary_under_a_fixed_name(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""The install script addresses members by name, not by discovery."""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
|
||||
payload, manifest = agentstore.build_current_package()
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
assert archive.testzip() is None
|
||||
names = archive.namelist()
|
||||
assert agentstore.PACKAGE_MEMBER_NAMES['agent'] in names
|
||||
assert manifest['agentversion'] == '1.16.0.0'
|
||||
assert manifest['contents']['agent']['version'] == '1.16.0.0'
|
||||
|
||||
|
||||
def test_the_written_package_file_matches_the_recorded_digest(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""A row whose digest disagrees with the file on disk is unpushable."""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
row = _agent_row()
|
||||
|
||||
path = agentregistry._package_path(row.appid)
|
||||
assert os.path.isfile(path)
|
||||
with open(path, 'rb') as handle:
|
||||
ondisk = hashlib.sha256(handle.read()).hexdigest()
|
||||
assert ondisk == row.installersha256
|
||||
# The atomic-write temp file must not survive a successful write.
|
||||
assert not os.path.isfile(path + '.tmp')
|
||||
|
||||
|
||||
def test_rolling_back_repoints_the_row_at_the_restored_build(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""A rollback changes the bytes the fleet should get.
|
||||
|
||||
If only upload re-registered, the row would still pin the build the
|
||||
operator just rolled away from, and the push would ship it anyway.
|
||||
"""
|
||||
_upload(client, auth_headers, monkeypatch, '1.16.0.0',
|
||||
payload=b'MZ' + b'\x01' * 256)
|
||||
oldsha = _agent_row().installersha256
|
||||
|
||||
_upload(client, auth_headers, monkeypatch, '1.17.0.0',
|
||||
payload=b'MZ' + b'\x02' * 256)
|
||||
newsha = _agent_row().installersha256
|
||||
assert newsha != oldsha
|
||||
|
||||
response = client.put(CURRENT_URL.format('agent'),
|
||||
json={'version': '1.16.0.0'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['registration']['ok'] is True
|
||||
|
||||
assert agentstore.current_version('agent') == '1.16.0.0'
|
||||
# Back to the rolled-to build's package, not the newer one.
|
||||
assert _agent_row().installersha256 == oldsha
|
||||
|
||||
|
||||
def test_a_registration_failure_does_not_fail_the_upload(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""The binary is already staged and audited by the time this runs.
|
||||
|
||||
Turning a packaging problem into an error response would tell the operator
|
||||
the upload was rejected when it actually succeeded, inviting a re-upload of
|
||||
a build that is already there.
|
||||
"""
|
||||
def _explode():
|
||||
raise OSError('simulated packaging failure')
|
||||
|
||||
monkeypatch.setattr(agentregistry.agentstore, 'build_current_package',
|
||||
_explode)
|
||||
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.16.0.0')
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()['data']
|
||||
# The staging itself still stands...
|
||||
assert body['current'] == '1.16.0.0'
|
||||
# ...and the failure is surfaced rather than swallowed.
|
||||
assert body['registration']['ok'] is False
|
||||
assert 'simulated packaging failure' in body['registration']['error']
|
||||
|
||||
|
||||
def test_staging_only_a_tray_reports_that_there_is_nothing_to_package(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
"""The bundle is defined by the agent; a tray alone is not pushable."""
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.6.5.0',
|
||||
binkey='tray')
|
||||
|
||||
assert response.status_code == 200
|
||||
registration = response.get_json()['data']['registration']
|
||||
assert registration['ok'] is False
|
||||
assert 'no current agent build' in registration['error']
|
||||
assert _agent_row() is None
|
||||
|
||||
|
||||
def test_these_tests_do_not_touch_the_real_agent_store(
|
||||
client, db, app, auth_headers, monkeypatch, isolate_store):
|
||||
"""Guard the isolation itself.
|
||||
|
||||
An earlier draft of this file wrote fake 130-byte binaries into the repo's
|
||||
real instance/siteagent/ tree and invented a version in its manifest,
|
||||
because create_app('testing') shares the developer's instance_path. If that
|
||||
redirection ever regresses, fail here rather than silently corrupting a
|
||||
staged build again.
|
||||
"""
|
||||
_upload(client, auth_headers, monkeypatch, '9.9.9.9')
|
||||
|
||||
# The staged build landed inside the per-test directory...
|
||||
staged = isolate_store / 'siteagent' / 'agent' / '9.9.9.9'
|
||||
assert staged.is_dir()
|
||||
|
||||
# ...and the real store never saw it.
|
||||
realstore = os.path.join(app.instance_path, 'siteagent', 'agent', '9.9.9.9')
|
||||
assert not os.path.exists(realstore)
|
||||
329
tests/test_plugins/test_endpoints_agent_upgrade_route.py
Normal file
329
tests/test_plugins/test_endpoints_agent_upgrade_route.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""The agent self-upgrade enqueue route.
|
||||
|
||||
Replacing the agent is self-modifying the control plane: the process that
|
||||
receives the command is the one being swapped. The route therefore sits behind
|
||||
endpoints.agentupdate, and - more importantly - it does NOT implement its own
|
||||
enqueue. It prepares the delivery and delegates to enqueue_endpoint_command so
|
||||
the endpoint lock, the Configuration Policy gate, stale-command reaping and the
|
||||
at-most-one-in-flight rule all still apply.
|
||||
|
||||
These tests pin that delegation. A parallel enqueue path that quietly skipped a
|
||||
lock or the in-flight rule would be invisible in normal use and would show up
|
||||
as a fleet-wide agent swap landing on a quarantined machine.
|
||||
|
||||
The store is isolated per test: create_app('testing') resolves instance_path to
|
||||
the real repo instance/, so without redirection these would stage binaries into
|
||||
the developer's actual agent store.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from plugins.endpoints.api import agentstore, agentregistry, agentupgradescript
|
||||
|
||||
UPGRADE_URL = '/api/endpoints/{}/agentupgrade'
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 4096
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_store(tmp_path, monkeypatch):
|
||||
"""Redirect the agent store, package output and script bodies."""
|
||||
siteagent = tmp_path / 'siteagent'
|
||||
packages = tmp_path / 'applicationpackages'
|
||||
scripts = tmp_path / 'scripts'
|
||||
for directory in (siteagent, packages, scripts):
|
||||
directory.mkdir()
|
||||
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
|
||||
monkeypatch.setattr(agentregistry, 'application_package_location',
|
||||
lambda: (str(packages), '/api/applications/package/'))
|
||||
monkeypatch.setattr(agentupgradescript, 'script_body_location',
|
||||
lambda: (str(scripts), '/api/scripts/body/'))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _publish_agent(version='1.16.0.0'):
|
||||
"""Stage a build so there is something to upgrade to."""
|
||||
agentstore.store_version('agent', version, _PE_BYTES, make_current=True)
|
||||
|
||||
|
||||
def _endpoint(db, hostname, agentversion='1.15.0.0'):
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
|
||||
agentversion=agentversion))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _login(db, client, username, permissions=None, admin=False):
|
||||
"""A user holding exactly the named permissions (or the admin role)."""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
|
||||
rolename = 'admin' if admin else username + 'role'
|
||||
role = Role.query.filter_by(rolename=rolename).first()
|
||||
if role is None:
|
||||
role = Role(rolename=rolename)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
if not admin:
|
||||
for name in (permissions or []):
|
||||
perm = Permission.query.filter_by(name=name).first()
|
||||
if perm is not None and perm not in role.permissions:
|
||||
role.permissions.append(perm)
|
||||
db.session.commit()
|
||||
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def test_an_upgrade_queues_as_a_pinned_run_script(client, db, app):
|
||||
"""The push must ride the normal script channel, digest-pinned.
|
||||
|
||||
Landing as run_script is what makes the body verifiable on the endpoint;
|
||||
a bespoke action would need its own pinning and would not get it.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE01')
|
||||
headers = _login(db, client, 'upgradeadmin', admin=True)
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
command = EndpointCommand.query.filter_by(assetid=assetid).first()
|
||||
assert command is not None
|
||||
assert command.action == 'run_script'
|
||||
|
||||
seeded = agentupgradescript.seed_upgrade_script()
|
||||
assert command.target == 'script:{}'.format(seeded['scriptid'])
|
||||
# Pinned to the exact body approved at enqueue, so a later edit cannot
|
||||
# swap what the endpoint runs.
|
||||
assert command.pinnedsha256 == seeded['sha256']
|
||||
|
||||
|
||||
def test_the_upgrade_requires_the_agentupdate_permission(client, db, app):
|
||||
"""endpoints.command alone must not be able to swap the SYSTEM agent."""
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE02')
|
||||
headers = _login(db, client, 'plainoperator',
|
||||
permissions=['endpoints.view', 'endpoints.command'])
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_an_upgrade_is_refused_when_no_build_is_published(client, db, app):
|
||||
"""Nothing staged means there is no payload; refuse before queueing.
|
||||
|
||||
Queueing anyway would send an endpoint to fetch a package that does not
|
||||
exist and fail opaquely minutes later.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'UPGRADE03')
|
||||
headers = _login(db, client, 'nobuildadmin', admin=True)
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
assert response.status_code == 400
|
||||
assert 'No agent build is published' in \
|
||||
response.get_json()['data']['error']['message']
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_an_endpoint_lock_still_blocks_the_upgrade(client, db, app):
|
||||
"""The delegate's lock check must not be bypassed by this route.
|
||||
|
||||
A quarantined machine is reserved by a person; a fleet agent swap is
|
||||
exactly the kind of action that must respect that.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE04')
|
||||
|
||||
# Lock as one user, then act as a different non-admin holder of the grant.
|
||||
owner = _login(db, client, 'lockowner', admin=True)
|
||||
locked = client.post(f'/api/endpoints/{assetid}/lock',
|
||||
json={'reason': 'bench repair'}, headers=owner)
|
||||
assert locked.status_code in (200, 201)
|
||||
|
||||
headers = _login(db, client, 'upgradeoperator',
|
||||
permissions=['endpoints.view', 'endpoints.command',
|
||||
'endpoints.agentupdate',
|
||||
'endpoints.runscript'])
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert 'locked' in \
|
||||
response.get_json()['data']['error']['message'].lower()
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_refused_callers_never_trigger_repackaging(client, db, app,
|
||||
monkeypatch):
|
||||
"""Lock and runscript checks run BEFORE the package and script are rebuilt.
|
||||
|
||||
Repackaging rewrites a shared file and script row; a caller who will be
|
||||
refused anyway must not be able to cause that side effect.
|
||||
"""
|
||||
calls = []
|
||||
monkeypatch.setattr(agentregistry, 'register_current_agent_package',
|
||||
lambda: calls.append('package'))
|
||||
monkeypatch.setattr(agentupgradescript, 'seed_upgrade_script',
|
||||
lambda: calls.append('script'))
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE09')
|
||||
|
||||
noscript = _login(db, client, 'noscriptoperator',
|
||||
permissions=['endpoints.view', 'endpoints.command',
|
||||
'endpoints.agentupdate'])
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=noscript)
|
||||
assert response.status_code == 403
|
||||
assert 'endpoints.runscript' in \
|
||||
response.get_json()['data']['error']['message']
|
||||
|
||||
owner = _login(db, client, 'lockowner2', admin=True)
|
||||
client.post(f'/api/endpoints/{assetid}/lock',
|
||||
json={'reason': 'bench repair'}, headers=owner)
|
||||
locked = _login(db, client, 'lockedoperator',
|
||||
permissions=['endpoints.view', 'endpoints.command',
|
||||
'endpoints.agentupdate',
|
||||
'endpoints.runscript'])
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=locked)
|
||||
assert response.status_code == 403
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_package_write_leaves_no_temp_files(client, db, app, isolate_store):
|
||||
_publish_agent()
|
||||
agentregistry.register_current_agent_package()
|
||||
agentregistry.register_current_agent_package()
|
||||
leftovers = [name for _root, _dirs, files in
|
||||
os.walk(isolate_store / 'applicationpackages')
|
||||
for name in files if name.endswith('.tmp')]
|
||||
assert leftovers == []
|
||||
|
||||
|
||||
def test_a_second_upgrade_cannot_stack_on_an_in_flight_command(client, db, app):
|
||||
"""at-most-one-in-flight must still hold through this route."""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE05')
|
||||
headers = _login(db, client, 'stackadmin', admin=True)
|
||||
|
||||
first = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert second.status_code == 409
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 1
|
||||
|
||||
|
||||
def test_the_upgrade_refreshes_the_package_before_queueing(client, db, app):
|
||||
"""A newer staged build must be what gets pushed.
|
||||
|
||||
If the route queued against a previously registered row, an operator who
|
||||
uploaded a new agent and immediately pushed would ship the old one.
|
||||
"""
|
||||
from shopdb.api import Application
|
||||
|
||||
_publish_agent('1.16.0.0')
|
||||
assetid = _endpoint(db, 'UPGRADE06')
|
||||
headers = _login(db, client, 'refreshadmin', admin=True)
|
||||
|
||||
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
row = Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).first()
|
||||
firstsha = row.installersha256
|
||||
|
||||
# Stage a different build and push to a second endpoint.
|
||||
agentstore.store_version('agent', '1.17.0.0', b'MZ' + b'\x01' * 4096,
|
||||
make_current=True)
|
||||
second = _endpoint(db, 'UPGRADE07')
|
||||
response = client.post(UPGRADE_URL.format(second), json={}, headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
db.session.expire_all()
|
||||
row = Application.query.filter_by(
|
||||
appname=agentregistry.AGENT_APPNAME).first()
|
||||
assert row.installersha256 != firstsha
|
||||
|
||||
|
||||
def test_a_failed_push_is_not_audited_as_an_upgrade(client, db, app):
|
||||
"""A refused push must not leave an AgentUpgrade event behind."""
|
||||
from shopdb.api import AuditLog
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE08')
|
||||
headers = _login(db, client, 'auditadmin', admin=True)
|
||||
|
||||
client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
before = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
|
||||
|
||||
# Second push is refused by the in-flight rule.
|
||||
blocked = client.post(UPGRADE_URL.format(assetid), json={}, headers=headers)
|
||||
assert blocked.status_code == 409
|
||||
|
||||
after = AuditLog.query.filter_by(entitytype='AgentUpgrade').count()
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_the_seeded_script_body_is_written_and_pinned(client, db, app,
|
||||
isolate_store):
|
||||
"""The agent verifies the body by digest, so both must exist and agree."""
|
||||
import hashlib
|
||||
|
||||
_publish_agent()
|
||||
assetid = _endpoint(db, 'UPGRADE09')
|
||||
headers = _login(db, client, 'bodyadmin', admin=True)
|
||||
|
||||
response = client.post(UPGRADE_URL.format(assetid), json={},
|
||||
headers=headers)
|
||||
assert response.status_code == 201
|
||||
|
||||
seeded = agentupgradescript.seed_upgrade_script()
|
||||
path = os.path.join(str(isolate_store / 'scripts'),
|
||||
'script-{}.ps1'.format(seeded['scriptid']))
|
||||
assert os.path.isfile(path)
|
||||
with open(path, 'rb') as handle:
|
||||
body = handle.read()
|
||||
assert hashlib.sha256(body).hexdigest() == seeded['sha256']
|
||||
# The delivery path must be fully resolved, not a literal placeholder.
|
||||
assert b'{{AGENT_APPID}}' not in body
|
||||
158
tests/test_plugins/test_endpoints_agent_version_guard.py
Normal file
158
tests/test_plugins/test_endpoints_agent_version_guard.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""The agent version guard on command enqueue.
|
||||
|
||||
An agent older than an action has no case for it and falls through to its
|
||||
unknown-action default, answering "Unknown or disallowed action". That reads as
|
||||
an authorization refusal, so the operator goes looking at permissions they
|
||||
already hold - which is exactly what happened on a live endpoint running 1.15.0
|
||||
after collect_log shipped in 1.16.0.
|
||||
|
||||
These tests pin the refusal at the SERVER, before the command is ever queued,
|
||||
and pin that the message names both versions so the answer is actionable.
|
||||
"""
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
def _endpoint(db, hostname, agentversion):
|
||||
"""An Asset + Computer pair whose reported state carries an agent version."""
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
|
||||
agentversion=agentversion))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _admin(db, client, username='guardadmin'):
|
||||
"""An admin, so permission and policy gates cannot be what refuses."""
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
role = Role.query.filter_by(rolename='admin').first()
|
||||
if role is None:
|
||||
role = Role(rolename='admin')
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _collect(client, headers, assetid):
|
||||
return client.post(f'/api/endpoints/{assetid}/commands',
|
||||
json={'action': 'collect_log',
|
||||
'target': r'C:\logs\app.log'},
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_an_agent_below_the_minimum_is_refused_at_enqueue(app, client, db):
|
||||
"""The command must not be queued at all.
|
||||
|
||||
Queueing it would spend the endpoint's one in-flight command slot and leave
|
||||
a failed row that reads as endpoint misbehaviour, when the truth is the
|
||||
agent simply predates the feature.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'oldagentbox', '1.15.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code == 400
|
||||
message = response.get_json()['data']['error']['message']
|
||||
# Both versions, or the operator cannot tell what to do about it.
|
||||
assert '1.16.0' in message
|
||||
assert '1.15.0.0' in message
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_an_agent_at_the_minimum_is_allowed(app, client, db):
|
||||
"""The guard must not refuse a capable endpoint.
|
||||
|
||||
1.16.0.0 against a 1.16.0 floor is the four-vs-three-part spelling of the
|
||||
same build; a lexical compare would get this wrong.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'newagentbox', '1.16.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code in (200, 201)
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid,
|
||||
action='collect_log').count() == 1
|
||||
|
||||
|
||||
def test_a_lexically_larger_but_older_version_is_still_refused(app, client, db):
|
||||
"""'1.9.0.0' must not pass a '1.16.0' floor.
|
||||
|
||||
String comparison says '9' > '1' and lets it through. This is the specific
|
||||
bug the numeric parse exists to prevent, so it gets its own test rather
|
||||
than living only in the helper's unit coverage.
|
||||
"""
|
||||
assetid = _endpoint(db, 'lexicaltrapbox', '1.9.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert '1.16.0' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_an_unknown_agent_version_is_refused_rather_than_guessed(app, client, db):
|
||||
"""A placeholder version fails closed.
|
||||
|
||||
Seeded rows carry '0.0.0-sample'. There is no honest ordering between a
|
||||
placeholder and a real build, and letting it through only moves the failure
|
||||
to the agent where the error message is the misleading one.
|
||||
"""
|
||||
assetid = _endpoint(db, 'samplebox', '0.0.0-sample')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = _collect(client, headers, assetid)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_an_action_without_a_floor_is_unaffected(app, client, db):
|
||||
"""The guard is per-action, not global.
|
||||
|
||||
collect_now declares no minimum, so an old agent must still be able to run
|
||||
it - otherwise adding a floor to one action quietly narrows the others.
|
||||
"""
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'refreshbox', '1.11.0.0')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/commands',
|
||||
json={'action': 'collect_now'}, headers=headers)
|
||||
|
||||
assert response.status_code in (200, 201)
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid,
|
||||
action='collect_now').count() == 1
|
||||
90
tests/test_plugins/test_endpoints_agentupload.py
Normal file
90
tests/test_plugins/test_endpoints_agentupload.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Agent binary upload version gating: PE-fallback and precedence.
|
||||
|
||||
The upload route no longer rejects the version at raw input. A typed value is
|
||||
only a hint; the dotted-numeric FILE version in the binary's PE resource is the
|
||||
authority. The route falls back to it when the typed value is missing or not
|
||||
dotted-numeric, and only refuses when neither yields a usable version.
|
||||
|
||||
Building a real PE with a RT_VERSION resource inline is impractical (see the
|
||||
agentstore unit tests), so these API tests monkeypatch read_pe_file_version to
|
||||
stand in for the parser. PE-magic bytes keep looks_like_pe happy so the request
|
||||
reaches the version gate.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from plugins.endpoints.api import agentstore
|
||||
from plugins.endpoints.api import agentupload
|
||||
from shopdb.config import Config
|
||||
|
||||
UPLOAD_URL = '/api/endpoints/siteagent/binaries/{}'
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 128
|
||||
|
||||
|
||||
def _upload(client, headers, monkeypatch, detected, typed=None):
|
||||
monkeypatch.setattr(agentstore, 'read_pe_file_version',
|
||||
lambda data: detected)
|
||||
form = {'file': (io.BytesIO(_PE_BYTES), 'shopdbagent.exe')}
|
||||
if typed is not None:
|
||||
form['version'] = typed
|
||||
return client.post(
|
||||
UPLOAD_URL.format('agent'),
|
||||
data=form,
|
||||
content_type='multipart/form-data',
|
||||
headers=headers)
|
||||
|
||||
|
||||
def test_upload_falls_back_to_pe_version_when_typed_is_missing(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
payload = response.get_json()['data']
|
||||
assert payload['current'] == '1.5.0.0'
|
||||
assert any(v['version'] == '1.5.0.0' for v in payload['versions'])
|
||||
|
||||
|
||||
def test_upload_falls_back_when_typed_is_not_dotted_numeric(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, '1.5.0.0',
|
||||
typed='1.5.0+1a1bf0640f956634dada1b34840')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['current'] == '1.5.0.0'
|
||||
|
||||
|
||||
def test_upload_prefers_valid_typed_version_over_pe(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, '9.9.9.9',
|
||||
typed='1.6.3')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['current'] == '1.6.3'
|
||||
|
||||
|
||||
def test_upload_rejects_when_neither_typed_nor_pe_is_usable(
|
||||
client, db, app, auth_headers, monkeypatch):
|
||||
response = _upload(client, auth_headers, monkeypatch, None)
|
||||
assert response.status_code == 400
|
||||
assert 'dotted-numeric' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_upload_cap_admits_a_self_contained_single_file_publish():
|
||||
"""The tray publishes ~69 MB; the cap must clear a real binary.
|
||||
|
||||
This is the bug that presented as "upload does not detect version": the
|
||||
size check returns before the PE is parsed, so an oversized file looks
|
||||
like a parser failure to the operator. Pinned at 100 MB rather than the
|
||||
observed 69 MB so runtime growth does not silently re-break it.
|
||||
"""
|
||||
assert agentupload._MAX_UPLOAD_BYTES >= 100 * 1024 * 1024
|
||||
|
||||
|
||||
def test_request_cap_stays_above_the_agent_upload_cap():
|
||||
"""MAX_CONTENT_LENGTH bounds the whole multipart envelope, not the file.
|
||||
|
||||
Setting the two equal rejects a file sitting exactly at the agent cap,
|
||||
because the boundaries and form fields push the request over. They must
|
||||
move together, with real headroom between them.
|
||||
"""
|
||||
envelope = Config.MAX_CONTENT_LENGTH
|
||||
assert envelope > agentupload._MAX_UPLOAD_BYTES
|
||||
assert envelope - agentupload._MAX_UPLOAD_BYTES >= 1024 * 1024
|
||||
165
tests/test_plugins/test_endpoints_app_conflict.py
Normal file
165
tests/test_plugins/test_endpoints_app_conflict.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""A concurrent application insert must not sink the whole check-in.
|
||||
|
||||
The unique index on (assetid, appfingerprint) detects a second writer inserting
|
||||
the same application row; it does not resolve one. Before the savepoint went in,
|
||||
the IntegrityError from the insert flush escaped generic_collect, reached the
|
||||
global handler, rolled back the WHOLE check-in - state, events and hardware -
|
||||
and answered the agent with operator-facing 409 form copy.
|
||||
|
||||
The insert now behaves like the delete-count mismatch beside it: warn, keep the
|
||||
telemetry, and let the next check-in (which reports the full list again) settle
|
||||
the inventory.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from plugins.endpoints.models import (
|
||||
EndpointApplication, EndpointEvent, EndpointState,
|
||||
)
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
|
||||
def _fingerprint(name, version, publisher):
|
||||
data = '{}|{}|{}'.format(name or '', version or '', publisher or '')
|
||||
return hashlib.sha256(data.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _payload(hostname, applications):
|
||||
return {
|
||||
'hostname': hostname,
|
||||
'pendingreboot': True,
|
||||
'applications': applications,
|
||||
'events': [{
|
||||
'eventid': 4625,
|
||||
'eventrecordid': 501,
|
||||
'eventchannel': 'Security',
|
||||
'eventsource': 'Microsoft-Windows-Security-Auditing',
|
||||
'eventmessage': 'Failed logon',
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_insert_conflict_keeps_the_checkin(db, monkeypatch):
|
||||
"""A REAL IntegrityError on the insert flush leaves the check-in intact.
|
||||
|
||||
The first (bulk) insert adds a genuine duplicate row, so the flush raises on
|
||||
the unique index exactly as a concurrent check-in would make it. The
|
||||
reconcile then re-reads and re-inserts, and state plus events still commit.
|
||||
"""
|
||||
assetid = _computer(db, 'APPRACE01')
|
||||
plugin = EndpointsPlugin()
|
||||
|
||||
real_insert = EndpointsPlugin._insert_applications
|
||||
calls = []
|
||||
|
||||
def conflicting_insert(assetid_arg, incoming, fingerprints):
|
||||
rows = real_insert(assetid_arg, incoming, fingerprints)
|
||||
calls.append(list(fingerprints))
|
||||
if len(calls) == 1:
|
||||
# The conflicting row a second writer would have committed.
|
||||
first = rows[0]
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=first.assetid,
|
||||
displayname=first.displayname,
|
||||
displayversion=first.displayversion,
|
||||
publisher=first.publisher,
|
||||
appfingerprint=first.appfingerprint,
|
||||
))
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(EndpointsPlugin, '_insert_applications',
|
||||
staticmethod(conflicting_insert))
|
||||
|
||||
applications = [
|
||||
{'displayname': 'Alpha', 'displayversion': '1.0', 'publisher': 'ACME',
|
||||
'fingerprint': _fingerprint('Alpha', '1.0', 'ACME')},
|
||||
{'displayname': 'Beta', 'displayversion': '2.0', 'publisher': 'ACME',
|
||||
'fingerprint': _fingerprint('Beta', '2.0', 'ACME')},
|
||||
]
|
||||
|
||||
result = plugin.apply_collector_payload(_payload('APPRACE01', applications))
|
||||
db.session.commit()
|
||||
|
||||
# The collision was detected and reported rather than raised.
|
||||
assert len(calls) > 1, 'the bulk insert should have collided and reconciled'
|
||||
assert any('application insert conflict' in w for w in result['warnings'])
|
||||
|
||||
# The telemetry the check-in carried is still there.
|
||||
state = EndpointState.query.filter_by(assetid=assetid).first()
|
||||
assert state is not None
|
||||
assert state.pendingreboot is True
|
||||
assert state.lastreportedat is not None
|
||||
assert EndpointEvent.query.filter_by(assetid=assetid).count() == 1
|
||||
|
||||
# And the inventory reconciled rather than being abandoned.
|
||||
stored = {row.appfingerprint for row
|
||||
in EndpointApplication.query.filter_by(assetid=assetid).all()}
|
||||
assert stored == {app['fingerprint'] for app in applications}
|
||||
|
||||
|
||||
def test_reconcile_skips_fingerprints_that_now_exist(db):
|
||||
"""Reconcile inserts only what is missing and counts the rest as skipped."""
|
||||
assetid = _computer(db, 'APPRACE02')
|
||||
plugin = EndpointsPlugin()
|
||||
|
||||
taken = _fingerprint('Alpha', '1.0', 'ACME')
|
||||
missing = _fingerprint('Beta', '2.0', 'ACME')
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid, displayname='Alpha', displayversion='1.0',
|
||||
publisher='ACME', appfingerprint=taken))
|
||||
db.session.commit()
|
||||
|
||||
incoming = {
|
||||
taken: {'displayname': 'Alpha', 'displayversion': '1.0',
|
||||
'publisher': 'ACME'},
|
||||
missing: {'displayname': 'Beta', 'displayversion': '2.0',
|
||||
'publisher': 'ACME'},
|
||||
}
|
||||
warnings = []
|
||||
stored = plugin._reconcile_applications(assetid, incoming,
|
||||
[taken, missing], warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert stored == 1
|
||||
assert warnings == ['application insert conflict: stored 1, skipped 1 '
|
||||
'of 2 new (concurrent update?)']
|
||||
rows = EndpointApplication.query.filter_by(assetid=assetid).all()
|
||||
assert len(rows) == 2
|
||||
assert {r.appfingerprint for r in rows} == {taken, missing}
|
||||
|
||||
|
||||
def test_the_unique_index_is_really_there(db):
|
||||
"""The whole fix rests on the constraint raising. Prove it does."""
|
||||
assetid = _computer(db, 'APPRACE03')
|
||||
fingerprint = _fingerprint('Alpha', '1.0', 'ACME')
|
||||
for _ in range(2):
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid, displayname='Alpha', displayversion='1.0',
|
||||
publisher='ACME', appfingerprint=fingerprint))
|
||||
raised = False
|
||||
try:
|
||||
db.session.flush()
|
||||
except IntegrityError:
|
||||
raised = True
|
||||
db.session.rollback()
|
||||
assert raised, 'endpointapplications has no unique (assetid, appfingerprint)'
|
||||
377
tests/test_plugins/test_endpoints_app_delta.py
Normal file
377
tests/test_plugins/test_endpoints_app_delta.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""Tests for application inventory delta-based updates.
|
||||
|
||||
Tests the _replace_applications() method with fingerprint-based delta logic,
|
||||
including safety features: fingerprint validation, delete count verification,
|
||||
memory limits, and concurrent update protection.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.endpoints.models import EndpointApplication
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
|
||||
def compute_test_fingerprint(name, version, publisher):
|
||||
"""Compute fingerprint matching agent algorithm."""
|
||||
name = name or ''
|
||||
version = version or ''
|
||||
publisher = publisher or ''
|
||||
data = f"{name}|{version}|{publisher}"
|
||||
return hashlib.sha256(data.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _make_asset(db, assetnumber):
|
||||
"""A bare computer-type Asset; _replace_applications needs only the id."""
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=assetnumber, name=assetnumber,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
class TestApplicationDelta:
|
||||
"""Test delta-based application inventory updates."""
|
||||
|
||||
@pytest.fixture
|
||||
def plugin(self):
|
||||
return EndpointsPlugin()
|
||||
|
||||
@pytest.fixture
|
||||
def asset_id(self, db):
|
||||
"""Create a test asset."""
|
||||
return _make_asset(db, 'TEST-001')
|
||||
|
||||
def test_fingerprint_validation_valid(self, plugin):
|
||||
"""Valid fingerprints (64 lowercase hex chars) pass validation."""
|
||||
valid_fps = [
|
||||
'a' * 64, # All lowercase a
|
||||
'0' * 64, # All zeros
|
||||
'abc123' * 10 + 'abcd', # Mix of hex chars (64 total)
|
||||
]
|
||||
|
||||
for fp in valid_fps:
|
||||
assert plugin._is_valid_fingerprint(fp), f"Should accept: {fp}"
|
||||
|
||||
def test_fingerprint_validation_invalid(self, plugin):
|
||||
"""Invalid fingerprints are rejected."""
|
||||
invalid_fps = [
|
||||
'', # Empty
|
||||
'abc', # Too short
|
||||
'A' * 64, # Uppercase
|
||||
'g' * 64, # Non-hex character
|
||||
'abc-def' * 9, # Contains hyphens
|
||||
'0' * 63, # One char too short
|
||||
'0' * 65, # One char too long
|
||||
]
|
||||
|
||||
for fp in invalid_fps:
|
||||
assert not plugin._is_valid_fingerprint(fp), f"Should reject: {fp}"
|
||||
|
||||
def test_delta_no_changes(self, plugin, asset_id, db):
|
||||
"""No apps added/removed when inventory unchanged."""
|
||||
# Initial insert: 3 apps
|
||||
apps_v1 = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
|
||||
},
|
||||
{
|
||||
'displayname': 'Firefox',
|
||||
'displayversion': '119.0',
|
||||
'publisher': 'Mozilla',
|
||||
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
|
||||
},
|
||||
{
|
||||
'displayname': 'Office',
|
||||
'displayversion': '16.0',
|
||||
'publisher': 'Microsoft',
|
||||
'fingerprint': compute_test_fingerprint('Office', '16.0', 'Microsoft')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps_v1, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 3
|
||||
assert len(warnings) == 0
|
||||
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 3
|
||||
|
||||
# Second report: same apps (no changes)
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps_v1, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 0 # 0 added
|
||||
assert len(warnings) == 0
|
||||
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 3 # Still 3
|
||||
|
||||
def test_delta_add_remove(self, plugin, asset_id, db):
|
||||
"""Detect adds and removes correctly."""
|
||||
# Initial: 2 apps
|
||||
apps_v1 = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
|
||||
},
|
||||
{
|
||||
'displayname': 'Firefox',
|
||||
'displayversion': '119.0',
|
||||
'publisher': 'Mozilla',
|
||||
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
plugin._replace_applications(asset_id, apps_v1, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 2
|
||||
|
||||
# Update: Remove Firefox, Add Office
|
||||
apps_v2 = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
|
||||
},
|
||||
{
|
||||
'displayname': 'Office',
|
||||
'displayversion': '16.0',
|
||||
'publisher': 'Microsoft',
|
||||
'fingerprint': compute_test_fingerprint('Office', '16.0', 'Microsoft')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps_v2, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 1 # 1 added (Office)
|
||||
assert len(warnings) == 0
|
||||
|
||||
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
|
||||
assert len(apps) == 2
|
||||
app_names = {app.displayname for app in apps}
|
||||
assert app_names == {'Chrome', 'Office'}
|
||||
|
||||
def test_delta_version_change(self, plugin, asset_id, db):
|
||||
"""Version change detected (different fingerprint)."""
|
||||
# Initial: Chrome 117.0
|
||||
apps_v1 = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
plugin._replace_applications(asset_id, apps_v1, warnings)
|
||||
db.session.commit()
|
||||
|
||||
# Update: Chrome 118.0 (different fingerprint)
|
||||
apps_v2 = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '118.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': compute_test_fingerprint('Chrome', '118.0', 'Google')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps_v2, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 1 # 1 added (new version)
|
||||
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
|
||||
assert len(apps) == 1
|
||||
assert apps[0].displayversion == '118.0'
|
||||
|
||||
def test_invalid_fingerprint_skipped(self, plugin, asset_id, db):
|
||||
"""Apps with invalid fingerprints are skipped with warning."""
|
||||
apps = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': 'INVALID' # Too short
|
||||
},
|
||||
{
|
||||
'displayname': 'Firefox',
|
||||
'displayversion': '119.0',
|
||||
'publisher': 'Mozilla',
|
||||
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 1 # Only Firefox added
|
||||
assert len(warnings) == 1
|
||||
assert 'invalid fingerprint' in warnings[0].lower()
|
||||
assert 'Chrome' in warnings[0]
|
||||
|
||||
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
|
||||
assert len(apps) == 1
|
||||
assert apps[0].displayname == 'Firefox'
|
||||
|
||||
def test_legacy_fallback_no_fingerprints(self, plugin, asset_id, db):
|
||||
"""Legacy mode works when agent doesn't send fingerprints."""
|
||||
apps = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
# No fingerprint key
|
||||
},
|
||||
{
|
||||
'displayname': 'Firefox',
|
||||
'displayversion': '119.0',
|
||||
'publisher': 'Mozilla',
|
||||
# No fingerprint key
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 2
|
||||
assert len(warnings) == 0
|
||||
|
||||
apps = EndpointApplication.query.filter_by(assetid=asset_id).all()
|
||||
assert len(apps) == 2
|
||||
assert all(app.appfingerprint is None for app in apps)
|
||||
|
||||
def test_empty_inventory(self, plugin, asset_id, db):
|
||||
"""Empty inventory removes all apps."""
|
||||
# Initial: 2 apps
|
||||
apps_v1 = [
|
||||
{
|
||||
'displayname': 'Chrome',
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': compute_test_fingerprint('Chrome', '117.0', 'Google')
|
||||
},
|
||||
{
|
||||
'displayname': 'Firefox',
|
||||
'displayversion': '119.0',
|
||||
'publisher': 'Mozilla',
|
||||
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
plugin._replace_applications(asset_id, apps_v1, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 2
|
||||
|
||||
# Update: Empty list
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, [], warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 0
|
||||
assert EndpointApplication.query.filter_by(assetid=asset_id).count() == 0
|
||||
|
||||
def test_missing_displayname_skipped(self, plugin, asset_id, db):
|
||||
"""Apps without displayname are skipped."""
|
||||
apps = [
|
||||
{
|
||||
# No displayname
|
||||
'displayversion': '117.0',
|
||||
'publisher': 'Google',
|
||||
'fingerprint': compute_test_fingerprint('', '117.0', 'Google')
|
||||
},
|
||||
{
|
||||
'displayname': 'Firefox',
|
||||
'displayversion': '119.0',
|
||||
'publisher': 'Mozilla',
|
||||
'fingerprint': compute_test_fingerprint('Firefox', '119.0', 'Mozilla')
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 1 # Only Firefox added
|
||||
assert len(warnings) == 1
|
||||
assert 'no displayname' in warnings[0].lower()
|
||||
|
||||
def test_string_truncation(self, plugin, asset_id, db):
|
||||
"""Long strings are truncated to column limits."""
|
||||
apps = [
|
||||
{
|
||||
'displayname': 'A' * 300, # Exceeds 255 char limit
|
||||
'displayversion': 'B' * 200, # Exceeds 128 char limit
|
||||
'publisher': 'C' * 300, # Exceeds 255 char limit
|
||||
'fingerprint': compute_test_fingerprint('A' * 300, 'B' * 200, 'C' * 300)
|
||||
},
|
||||
]
|
||||
|
||||
warnings = []
|
||||
result = plugin._replace_applications(asset_id, apps, warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert result == 1
|
||||
app = EndpointApplication.query.filter_by(assetid=asset_id).first()
|
||||
assert len(app.displayname) == 255
|
||||
assert len(app.displayversion) == 128
|
||||
assert len(app.publisher) == 255
|
||||
|
||||
|
||||
class TestApplicationDeltaSafety:
|
||||
"""Test safety features: locking, delete verification, memory limits."""
|
||||
|
||||
@pytest.fixture
|
||||
def plugin(self):
|
||||
return EndpointsPlugin()
|
||||
|
||||
@pytest.fixture
|
||||
def asset_id(self, db):
|
||||
"""Create a test asset."""
|
||||
return _make_asset(db, 'TEST-SAFETY-001')
|
||||
|
||||
def test_compute_fingerprint_consistency(self):
|
||||
"""Fingerprint algorithm is deterministic."""
|
||||
fp1 = compute_test_fingerprint('Chrome', '117.0', 'Google')
|
||||
fp2 = compute_test_fingerprint('Chrome', '117.0', 'Google')
|
||||
assert fp1 == fp2
|
||||
|
||||
# Different inputs produce different fingerprints
|
||||
fp3 = compute_test_fingerprint('Chrome', '118.0', 'Google')
|
||||
assert fp1 != fp3
|
||||
|
||||
def test_fingerprint_null_handling(self):
|
||||
"""Null values handled correctly (converted to empty string)."""
|
||||
fp1 = compute_test_fingerprint('App', None, None)
|
||||
fp2 = compute_test_fingerprint('App', '', '')
|
||||
assert fp1 == fp2 # None and '' should produce same fingerprint
|
||||
|
||||
def test_fingerprint_format(self):
|
||||
"""Fingerprints are 64 lowercase hex chars."""
|
||||
fp = compute_test_fingerprint('Test', '1.0', 'Vendor')
|
||||
assert len(fp) == 64
|
||||
assert fp.islower()
|
||||
assert all(c in '0123456789abcdef' for c in fp)
|
||||
677
tests/test_plugins/test_endpoints_bootstrap_install.py
Normal file
677
tests/test_plugins/test_endpoints_bootstrap_install.py
Normal file
@@ -0,0 +1,677 @@
|
||||
"""One-line bootstrap installer: token lifecycle and the two public downloads.
|
||||
|
||||
The token is the whole authorization on a PC that has no agent, so these tests
|
||||
read the real routes rather than the helpers: that an admin mints and revokes,
|
||||
that anything not currently usable answers 404 rather than confirming itself,
|
||||
that only the package download spends a use, that the package can never carry a
|
||||
collector key, and that every fetch leaves an audit row with the caller's IP.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from plugins.endpoints.api import agentstore, siteagent
|
||||
from plugins.endpoints.models import EndpointBootstrapToken
|
||||
from shopdb.core.models import AuditLog, Setting
|
||||
|
||||
TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
|
||||
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
|
||||
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
|
||||
BASE_URL = 'https://shopdb.test/shopdb'
|
||||
TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
|
||||
QUERY_SETTING = 'plugin.endpoints.bootstrapquerytoken'
|
||||
# Admin-only staging, NOT C:\Windows\Temp: a standard user can pre-plant a
|
||||
# path there and have it run elevated.
|
||||
STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
|
||||
STAGED_SCRIPT = STAGE_DIR + r'\shopdb-bootstrap.ps1'
|
||||
ADMIN_SID = 'S-1-5-32-544'
|
||||
SYSTEM_SID = 'S-1-5-18'
|
||||
# The marker the admin "Download .ps1" link carries, so the server can tell an
|
||||
# admin's browser click apart from a bay still on a pre-change pasted command.
|
||||
UI_LINK_QUERY = 'via=download'
|
||||
|
||||
|
||||
def _expected_command(secret, base=BASE_URL):
|
||||
"""Stage admin-only, download with the token in a header, check the owner,
|
||||
run, delete. Never `irm | iex` - Defender kills that shape."""
|
||||
return (
|
||||
'powershell -ExecutionPolicy Bypass -c "'
|
||||
"try {{ New-Item -ItemType Directory -Force -Path '{dir}' "
|
||||
'| Out-Null; '
|
||||
"icacls '{dir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
|
||||
"'*{system}:(OI)(CI)F' | Out-Null; "
|
||||
'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
|
||||
"{{ Write-Host 'Refusing to run: could not restrict "
|
||||
"{dir} to administrators (icacls failed). Fix the directory "
|
||||
"or delete it, then run the command again.' -ForegroundColor Red; "
|
||||
'exit 6 }}; '
|
||||
"Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
|
||||
"Invoke-WebRequest -UseBasicParsing "
|
||||
"'{base}/api/endpoints/siteagent/bootstrap.ps1' "
|
||||
"-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
|
||||
"if ((Get-Acl '{path}').GetOwner("
|
||||
'[Security.Principal.SecurityIdentifier]).Value -notin '
|
||||
"@('{admin}','{system}',"
|
||||
'[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
|
||||
"{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
|
||||
"owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
|
||||
"& '{path}' }} finally {{ Remove-Item '{path}' -Force "
|
||||
'-ErrorAction SilentlyContinue }}"'
|
||||
).format(dir=STAGE_DIR, path=STAGED_SCRIPT, base=base, header=TOKEN_HEADER,
|
||||
secret=secret, admin=ADMIN_SID, system=SYSTEM_SID)
|
||||
|
||||
|
||||
def _token_header(secret):
|
||||
return {TOKEN_HEADER: secret}
|
||||
|
||||
|
||||
def _make_cert():
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'server.test')])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
certificate = (x509.CertificateBuilder()
|
||||
.subject_name(name).issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=30))
|
||||
.sign(key, hashes.SHA256()))
|
||||
pem = certificate.public_bytes(serialization.Encoding.PEM)
|
||||
spki = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
return pem, hashlib.sha256(spki).hexdigest()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate(tmp_path, monkeypatch, app, db):
|
||||
"""Empty agent store, no auto-detected certificate, a known base URL."""
|
||||
store = tmp_path / 'siteagent'
|
||||
store.mkdir()
|
||||
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store))
|
||||
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ())
|
||||
Setting.set('site_base_url', BASE_URL, valuetype='string')
|
||||
# The limiter is process-wide cache state; a noisy neighbour test would
|
||||
# otherwise leak its budget into this module.
|
||||
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = False
|
||||
yield tmp_path
|
||||
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED', None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servercert(tmp_path, monkeypatch):
|
||||
pem, pin = _make_cert()
|
||||
path = tmp_path / 'server.crt'
|
||||
path.write_bytes(pem)
|
||||
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
|
||||
return pin
|
||||
|
||||
|
||||
def _mint(client, auth_headers, **body):
|
||||
return client.post(TOKENS_URL, json=body or {}, headers=auth_headers)
|
||||
|
||||
|
||||
def _secret(client, auth_headers, **body):
|
||||
response = _mint(client, auth_headers, **body)
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
return response.get_json()['data']
|
||||
|
||||
|
||||
def _files(response):
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:400]
|
||||
archive = zipfile.ZipFile(io.BytesIO(response.data))
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_generate_returns_the_secret_and_the_ready_made_command(
|
||||
client, db, auth_headers):
|
||||
data = _secret(client, auth_headers, hours=6, maxuses=3)
|
||||
|
||||
secret = data['secret']
|
||||
assert secret.startswith('sdbb_')
|
||||
assert len(secret) == len('sdbb_') + 43
|
||||
assert data['command'] == _expected_command(secret)
|
||||
assert data['scripturl'] == (
|
||||
'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}&{2}'.format(
|
||||
BASE_URL, secret, UI_LINK_QUERY))
|
||||
token = data['token']
|
||||
assert token['tokenprefix'] == secret[:12]
|
||||
assert token['maxuses'] == 3
|
||||
assert token['usecount'] == 0
|
||||
assert token['remaining'] == 3
|
||||
assert token['state'] == 'active'
|
||||
assert token['createdby'] == 'testadmin'
|
||||
|
||||
|
||||
def test_the_command_downloads_and_runs_instead_of_piping_into_iex(
|
||||
client, db, auth_headers):
|
||||
"""Defender detects `irm ... | iex` as Trojan:Win32/Commando.A!ml and kills
|
||||
the process before a line of it runs, so the published command downloads to
|
||||
a file, runs it and removes it. It also stays one pasteable line, and holds
|
||||
no `$`: pasted into a PowerShell prompt, the parent would expand those
|
||||
before the child process ever saw them."""
|
||||
data = _secret(client, auth_headers)
|
||||
command = data['command']
|
||||
|
||||
assert ' | iex' not in command
|
||||
assert 'iex' not in command.lower()
|
||||
assert '$' not in command
|
||||
assert '\n' not in command
|
||||
assert data['secret'] in command
|
||||
assert "Remove-Item '{0}' -Force".format(STAGED_SCRIPT) in command
|
||||
assert 'finally' in command
|
||||
# The link the UI offers instead is the route that really serves it.
|
||||
assert client.get(data['scripturl'].split(BASE_URL, 1)[1]
|
||||
).status_code == 200
|
||||
|
||||
|
||||
def test_the_command_stages_where_only_administrators_can_write(
|
||||
client, db, auth_headers):
|
||||
"""C:\\Windows\\Temp lets any standard user create the exact path the
|
||||
command runs elevated, so the staging directory moved under Program Files,
|
||||
is created with an admin-only DACL, and the command refuses to run a staged
|
||||
file owned by anyone but an administrator."""
|
||||
command = _secret(client, auth_headers)['command']
|
||||
|
||||
assert r'C:\Windows\Temp' not in command
|
||||
assert "-OutFile '{0}'".format(STAGED_SCRIPT) in command
|
||||
assert "New-Item -ItemType Directory -Force -Path '{0}'".format(
|
||||
STAGE_DIR) in command
|
||||
assert "icacls '{0}' /inheritance:r".format(STAGE_DIR) in command
|
||||
# Well-known SIDs, so a renamed or localized group cannot widen the grant.
|
||||
assert "/grant '*{0}:(OI)(CI)F' '*{1}:(OI)(CI)F'".format(
|
||||
ADMIN_SID, SYSTEM_SID) in command
|
||||
assert "Get-Acl '{0}'".format(STAGED_SCRIPT) in command
|
||||
assert 'GetOwner([Security.Principal.SecurityIdentifier]).Value' in command
|
||||
assert "-notin @('{0}','{1}',".format(ADMIN_SID, SYSTEM_SID) in command
|
||||
assert '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value' \
|
||||
in command
|
||||
assert 'not owned by an administrator' in command
|
||||
assert 'exit 5' in command
|
||||
# A leftover from an earlier run is cleared before the download, so the
|
||||
# owner test cannot pass on stale content.
|
||||
assert command.index("Remove-Item '{0}'".format(STAGED_SCRIPT)) < \
|
||||
command.index('Invoke-WebRequest')
|
||||
|
||||
|
||||
def test_the_command_sends_the_token_in_a_header_not_the_query_string(
|
||||
client, db, auth_headers):
|
||||
"""A query string lands verbatim in the nginx and gunicorn access logs."""
|
||||
data = _secret(client, auth_headers)
|
||||
command = data['command']
|
||||
|
||||
assert '?t=' not in command
|
||||
assert "-Headers @{{'{0}'='{1}'}}".format(TOKEN_HEADER,
|
||||
data['secret']) in command
|
||||
assert "'{0}/api/endpoints/siteagent/bootstrap.ps1'".format(
|
||||
BASE_URL) in command
|
||||
# The browser download link is the one place that cannot send a header,
|
||||
# and it is marked so its own fetches are not counted as un-migrated bays.
|
||||
assert data['scripturl'].endswith(
|
||||
'?t={0}&{1}'.format(data['secret'], UI_LINK_QUERY))
|
||||
|
||||
|
||||
def test_a_failed_acl_hardening_stops_the_command(client, db, auth_headers):
|
||||
"""icacls is a native command: it raises nothing and $ErrorActionPreference
|
||||
never applies to it, so an unchecked call leaves a staging directory that
|
||||
still inherits the parent DACL and the line downloads into it anyway. The
|
||||
exit code is read sigil-free, because the whole line must hold no `$`."""
|
||||
command = _secret(client, auth_headers)['command']
|
||||
|
||||
assert '(Get-Variable LASTEXITCODE -ValueOnly) -ne 0' in command
|
||||
assert 'LASTEXITCODE' in command and '$LASTEXITCODE' not in command
|
||||
assert 'icacls failed' in command
|
||||
assert 'exit 6' in command
|
||||
# Checked before anything is fetched into the directory, not after.
|
||||
assert command.index('LASTEXITCODE') > command.index('icacls')
|
||||
assert command.index('LASTEXITCODE') < command.index('Invoke-WebRequest')
|
||||
|
||||
|
||||
def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers):
|
||||
token = _secret(client, auth_headers)['token']
|
||||
assert token['maxuses'] == 25
|
||||
created = datetime.datetime.fromisoformat(token['createdat'][:-1])
|
||||
expires = datetime.datetime.fromisoformat(token['expiresat'][:-1])
|
||||
assert round((expires - created).total_seconds() / 3600) == 24
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
{'hours': 0}, {'hours': 169}, {'maxuses': 0}, {'maxuses': 501},
|
||||
{'hours': 'soon'},
|
||||
])
|
||||
def test_out_of_range_inputs_are_refused(client, db, auth_headers, body):
|
||||
assert _mint(client, auth_headers, **body).status_code == 400
|
||||
|
||||
|
||||
def test_list_never_carries_a_secret(client, db, auth_headers):
|
||||
data = _secret(client, auth_headers)
|
||||
response = client.get(TOKENS_URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
tokens = response.get_json()['data']['tokens']
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0]['tokenprefix'] == data['secret'][:12]
|
||||
body = response.get_data(as_text=True)
|
||||
assert data['secret'] not in body
|
||||
assert 'secret' not in tokens[0]
|
||||
assert 'tokenhash' not in tokens[0]
|
||||
|
||||
|
||||
def test_revoke_kills_the_token(client, db, auth_headers):
|
||||
data = _secret(client, auth_headers)
|
||||
tokenid = data['token']['tokenid']
|
||||
|
||||
response = client.post('{0}/{1}/revoke'.format(TOKENS_URL, tokenid),
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['token']['state'] == 'revoked'
|
||||
# Idempotent: revoking twice is not an error.
|
||||
assert client.post('{0}/{1}/revoke'.format(TOKENS_URL, tokenid),
|
||||
headers=auth_headers).status_code == 200
|
||||
assert client.post('{0}/999/revoke'.format(TOKENS_URL),
|
||||
headers=auth_headers).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method,path', [
|
||||
('post', TOKENS_URL),
|
||||
('get', TOKENS_URL),
|
||||
('post', TOKENS_URL + '/1/revoke'),
|
||||
])
|
||||
def test_only_an_admin_can_manage_tokens(client, db, member_headers, method,
|
||||
path):
|
||||
anonymous = getattr(client, method)(path)
|
||||
assert anonymous.status_code in (401, 422)
|
||||
member = getattr(client, method)(path, headers=member_headers)
|
||||
assert member.status_code == 403
|
||||
|
||||
|
||||
def test_generate_and_revoke_write_audit_rows(client, db, auth_headers):
|
||||
data = _secret(client, auth_headers)
|
||||
client.post('{0}/{1}/revoke'.format(TOKENS_URL, data['token']['tokenid']),
|
||||
headers=auth_headers)
|
||||
|
||||
rows = AuditLog.query.filter_by(
|
||||
entitytype='EndpointBootstrapToken').order_by(AuditLog.auditlogid).all()
|
||||
assert [row.action for row in rows] == ['created', 'updated']
|
||||
assert all(row.entityname == data['secret'][:12] for row in rows)
|
||||
assert all(row.username == 'testadmin' for row in rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bootstrap.ps1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_script_is_served_for_a_good_token(client, db, auth_headers):
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
response = client.get(SCRIPT_URL, query_string={'t': secret})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.mimetype == 'text/plain'
|
||||
script = response.get_data(as_text=True)
|
||||
script.encode('ascii')
|
||||
assert '"{0}/api/endpoints/siteagent/bootstrap/package"'.format(
|
||||
BASE_URL) in script
|
||||
assert 'Run as administrator' in script
|
||||
assert 'GE-Enforce' in script
|
||||
assert 'SHOPDB_BOOTSTRAP_FORCE' in script
|
||||
assert 'Settings > Endpoints > Enrollment' in script
|
||||
assert 'approved and enrolled' in script
|
||||
assert 'Get-FileHash' in script
|
||||
|
||||
|
||||
def test_script_bakes_the_staged_binary_hashes(client, db, auth_headers,
|
||||
tmp_path):
|
||||
payload = b'MZ' + b'agent-bytes'
|
||||
traypayload = b'MZ' + b'tray-bytes'
|
||||
agentstore.store_version('agent', '1.20.0', payload)
|
||||
agentstore.store_version('tray', '1.7.0', traypayload)
|
||||
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
script = client.get(SCRIPT_URL,
|
||||
query_string={'t': secret}).get_data(as_text=True)
|
||||
|
||||
assert hashlib.sha256(payload).hexdigest() in script
|
||||
assert hashlib.sha256(traypayload).hexdigest() in script
|
||||
|
||||
|
||||
def test_the_generated_script_fetches_the_package_with_the_header(
|
||||
client, db, auth_headers):
|
||||
"""Same reason as the one-liner: the package URL must not carry the token,
|
||||
or every agent install writes it into an access log."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
script = client.get(SCRIPT_URL,
|
||||
headers=_token_header(secret)).get_data(as_text=True)
|
||||
|
||||
assert '?t=' not in script
|
||||
assert '$BootstrapPackageHeaders = @{{ "{0}" = "{1}" }}'.format(
|
||||
TOKEN_HEADER, secret) in script
|
||||
assert ('Invoke-WebRequest -Uri $BootstrapPackageUrl '
|
||||
'-Headers $BootstrapPackageHeaders') in script
|
||||
|
||||
|
||||
def test_both_routes_take_the_token_in_a_header(client, db, auth_headers,
|
||||
servercert):
|
||||
secret = _secret(client, auth_headers, maxuses=2)['secret']
|
||||
|
||||
script = client.get(SCRIPT_URL, headers=_token_header(secret))
|
||||
assert script.status_code == 200
|
||||
assert 'ShopDB endpoint agent bootstrap' in script.get_data(as_text=True)
|
||||
assert 'install.ps1' in _files(
|
||||
client.get(PACKAGE_URL, headers=_token_header(secret)))
|
||||
assert EndpointBootstrapToken.query.one().usecount == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_a_bad_header_token_is_not_found(client, db, auth_headers, url):
|
||||
"""The header is read first, so a good ?t= cannot rescue a bad header - one
|
||||
request carries one token."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
response = client.get(url, query_string={'t': secret},
|
||||
headers=_token_header('sdbb_' + 'a' * 43))
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_the_query_form_still_works_and_is_audited_as_deprecated(
|
||||
client, db, auth_headers, caplog):
|
||||
"""A bay running a command pasted before this change must keep working, so
|
||||
the query form stays accepted; it is flagged in the audit row and warned
|
||||
about in the log, with no token value in either."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert client.get(SCRIPT_URL, query_string={'t': secret},
|
||||
environ_overrides={'REMOTE_ADDR': '10.0.0.6'}
|
||||
).status_code == 200
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='requested').one()
|
||||
assert 'query-string token, deprecated' in row.details
|
||||
assert secret not in row.details
|
||||
warnings = [record.getMessage() for record in caplog.records
|
||||
if record.levelno >= logging.WARNING]
|
||||
assert any('query string' in message and '10.0.0.6' in message
|
||||
for message in warnings)
|
||||
assert all(secret not in message for message in warnings)
|
||||
|
||||
|
||||
def test_the_header_form_is_not_flagged_as_deprecated(client, db, auth_headers,
|
||||
caplog):
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
with caplog.at_level(logging.WARNING):
|
||||
client.get(SCRIPT_URL, headers=_token_header(secret))
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='requested').one()
|
||||
assert 'deprecated' not in row.details
|
||||
assert not [record for record in caplog.records
|
||||
if record.levelno >= logging.WARNING]
|
||||
|
||||
|
||||
def test_the_admin_download_link_is_not_counted_as_an_un_migrated_bay(
|
||||
client, db, auth_headers, caplog):
|
||||
"""The rollout finds un-migrated bays by the deprecation WARNING, and the
|
||||
UI Download .ps1 link has to use the query form. Marked, it logs at info
|
||||
and is audited as the admin path, so an admin testing a token does not
|
||||
invent a bay that is not there."""
|
||||
data = _secret(client, auth_headers)
|
||||
path = data['scripturl'].split(BASE_URL, 1)[1]
|
||||
with caplog.at_level(logging.INFO):
|
||||
assert client.get(path, environ_overrides={'REMOTE_ADDR': '10.0.0.7'}
|
||||
).status_code == 200
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='requested').one()
|
||||
assert 'admin download link' in row.details
|
||||
assert 'deprecated' not in row.details
|
||||
assert data['secret'] not in row.details
|
||||
assert not [record for record in caplog.records
|
||||
if record.levelno >= logging.WARNING]
|
||||
infos = [record.getMessage() for record in caplog.records
|
||||
if record.levelno == logging.INFO]
|
||||
assert any('Download .ps1' in message and '10.0.0.7' in message
|
||||
for message in infos)
|
||||
assert all(data['secret'] not in message for message in infos)
|
||||
|
||||
|
||||
def test_no_log_or_audit_row_ever_carries_the_token_value(
|
||||
client, db, auth_headers, caplog, servercert):
|
||||
secret = _secret(client, auth_headers, maxuses=4)['secret']
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
client.get(SCRIPT_URL, query_string={'t': secret})
|
||||
client.get(SCRIPT_URL, headers=_token_header(secret))
|
||||
client.get(PACKAGE_URL, query_string={'t': secret})
|
||||
client.get(PACKAGE_URL, headers=_token_header(secret))
|
||||
|
||||
assert all(secret not in record.getMessage()
|
||||
for record in caplog.records)
|
||||
rows = AuditLog.query.filter_by(
|
||||
entitytype='EndpointBootstrapToken').all()
|
||||
assert rows
|
||||
for row in rows:
|
||||
assert secret not in (row.details or '')
|
||||
# The 12-char display prefix is the identifier the UI already shows.
|
||||
assert row.entityname == secret[:12]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_the_query_form_can_be_turned_off_once_a_site_has_re_issued(
|
||||
client, db, auth_headers, url):
|
||||
"""Off is the hardened end state, not the default: the UI download link
|
||||
uses the query form too, so a site only turns it off deliberately."""
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
Setting.set(QUERY_SETTING, False, valuetype='boolean')
|
||||
|
||||
refused = client.get(url, query_string={'t': secret})
|
||||
assert refused.status_code == 400
|
||||
assert TOKEN_HEADER in refused.get_json()['data']['error']['message']
|
||||
# The admin Download .ps1 link is a query token too: the setting is about
|
||||
# a live token landing in an access log, and that link puts one there.
|
||||
assert client.get(url, query_string={'t': secret, 'via': 'download'}
|
||||
).status_code == 400
|
||||
# Refused before any lookup, so it confirms nothing about the token.
|
||||
assert EndpointBootstrapToken.query.one().usecount == 0
|
||||
assert AuditLog.query.filter_by(
|
||||
entitytype='EndpointBootstrapToken', action='requested').count() == 0
|
||||
assert client.get(url, headers=_token_header(secret)).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_no_token_is_still_not_found_when_the_query_form_is_off(
|
||||
client, db, auth_headers, url):
|
||||
"""A bare GET presented no token, so it is unusable and answers 404 like
|
||||
every other unusable request. Calling it a query token answered 400 naming
|
||||
the header, which handed an anonymous caller the state of the switch."""
|
||||
_secret(client, auth_headers)
|
||||
Setting.set(QUERY_SETTING, False, valuetype='boolean')
|
||||
|
||||
assert client.get(url).status_code == 404
|
||||
# An empty ?t= presented nothing either.
|
||||
assert client.get(url, query_string={'t': ''}).status_code == 404
|
||||
# And it is the same answer with the query form still on.
|
||||
Setting.set(QUERY_SETTING, True, valuetype='boolean')
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
|
||||
def test_script_fetch_does_not_spend_a_use(client, db, auth_headers):
|
||||
secret = _secret(client, auth_headers, maxuses=1)['secret']
|
||||
for _ in range(3):
|
||||
assert client.get(SCRIPT_URL,
|
||||
query_string={'t': secret}).status_code == 200
|
||||
|
||||
row = EndpointBootstrapToken.query.one()
|
||||
assert row.usecount == 0
|
||||
|
||||
|
||||
def test_script_fetch_is_audited_with_the_caller_ip(client, db, auth_headers):
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
client.get(SCRIPT_URL, query_string={'t': secret},
|
||||
environ_overrides={'REMOTE_ADDR': '10.0.0.9'})
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='requested').one()
|
||||
assert row.ipaddress == '10.0.0.9'
|
||||
assert '10.0.0.9' in row.details
|
||||
assert row.username is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The package
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_package_is_served_and_spends_one_use(client, db, auth_headers,
|
||||
servercert):
|
||||
secret = _secret(client, auth_headers, maxuses=2)['secret']
|
||||
files = _files(client.get(PACKAGE_URL, query_string={'t': secret},
|
||||
environ_overrides={'REMOTE_ADDR': '10.0.0.8'}))
|
||||
|
||||
assert 'install.ps1' in files
|
||||
assert 'appsettings.json' in files
|
||||
row = EndpointBootstrapToken.query.one()
|
||||
assert row.usecount == 1
|
||||
assert row.lastusedip == '10.0.0.8'
|
||||
assert row.lastusedat is not None
|
||||
|
||||
|
||||
def test_package_carries_no_collector_key_and_does_carry_server_pins(
|
||||
client, db, auth_headers, servercert):
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
files = _files(client.get(PACKAGE_URL, query_string={'t': secret}))
|
||||
|
||||
for name, content in files.items():
|
||||
assert b'CollectorKey' not in content, name
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
assert '$sharedKeyMode = "omitted"' in script
|
||||
assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script
|
||||
settings = json.loads(files['appsettings.json'])
|
||||
assert settings['serverpins'] == [servercert]
|
||||
assert settings['apikey'] == ''
|
||||
assert b'Settings > Endpoints > Enrollment' in files['README.txt']
|
||||
|
||||
|
||||
def test_package_stays_keyless_even_when_the_shared_key_is_allowed(
|
||||
client, db, auth_headers, servercert):
|
||||
Setting.set('plugin.endpoints.sharedkeyallowed', True, valuetype='boolean')
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
files = _files(client.get(PACKAGE_URL, query_string={'t': secret}))
|
||||
|
||||
assert b'CollectorKey' not in files['install.ps1']
|
||||
|
||||
|
||||
def test_package_download_is_audited(client, db, auth_headers):
|
||||
secret = _secret(client, auth_headers)['secret']
|
||||
client.get(PACKAGE_URL, query_string={'t': secret},
|
||||
environ_overrides={'REMOTE_ADDR': '10.0.0.7'})
|
||||
|
||||
row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
|
||||
action='downloaded').one()
|
||||
assert row.ipaddress == '10.0.0.7'
|
||||
assert 'use 1 of' in row.details
|
||||
|
||||
|
||||
def test_a_token_is_refused_once_exhausted(client, db, auth_headers):
|
||||
secret = _secret(client, auth_headers, maxuses=1)['secret']
|
||||
assert client.get(PACKAGE_URL,
|
||||
query_string={'t': secret}).status_code == 200
|
||||
assert client.get(PACKAGE_URL,
|
||||
query_string={'t': secret}).status_code == 404
|
||||
# The script route is gated on the same usability test.
|
||||
assert client.get(SCRIPT_URL,
|
||||
query_string={'t': secret}).status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Everything unusable answers 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
@pytest.mark.parametrize('bad', ['', 'nonsense', 'sdbb_' + 'a' * 43,
|
||||
'sdbb_short'])
|
||||
def test_an_unknown_token_is_not_found(client, db, url, bad):
|
||||
assert client.get(url, query_string={'t': bad}).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_no_token_at_all_is_not_found(client, db, url):
|
||||
assert client.get(url).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_an_expired_token_is_not_found(client, db, auth_headers, url):
|
||||
secret = _secret(client, auth_headers, hours=1)['secret']
|
||||
row = EndpointBootstrapToken.query.one()
|
||||
row.expiresat = datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
|
||||
db.session.commit()
|
||||
|
||||
assert client.get(url, query_string={'t': secret}).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
|
||||
def test_a_revoked_token_is_not_found(client, db, auth_headers, url):
|
||||
data = _secret(client, auth_headers)
|
||||
client.post('{0}/{1}/revoke'.format(TOKENS_URL, data['token']['tokenid']),
|
||||
headers=auth_headers)
|
||||
|
||||
assert client.get(url, query_string={'t': data['secret']}).status_code == 404
|
||||
|
||||
|
||||
def test_a_bad_token_leaves_no_audit_row(client, db):
|
||||
client.get(SCRIPT_URL, query_string={'t': 'sdbb_' + 'a' * 43})
|
||||
assert AuditLog.query.filter_by(
|
||||
entitytype='EndpointBootstrapToken').count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_rate_limit_triggers_on_both_routes(client, db, app, auth_headers):
|
||||
secret = _secret(client, auth_headers, maxuses=500)['secret']
|
||||
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = True
|
||||
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX'] = 3
|
||||
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS'] = 60
|
||||
caller = {'REMOTE_ADDR': '10.9.9.9'}
|
||||
try:
|
||||
codes = [client.get(SCRIPT_URL, query_string={'t': secret},
|
||||
environ_overrides=caller).status_code
|
||||
for _ in range(4)]
|
||||
assert codes == [200, 200, 200, 429]
|
||||
# The budget is per IP, not per route: the package route is already over.
|
||||
limited = client.get(PACKAGE_URL, query_string={'t': secret},
|
||||
environ_overrides=caller)
|
||||
assert limited.status_code == 429
|
||||
assert limited.get_json()['data']['error']['code'] == 'RATE_LIMITED'
|
||||
# A different caller is unaffected.
|
||||
assert client.get(SCRIPT_URL, query_string={'t': secret},
|
||||
environ_overrides={'REMOTE_ADDR': '10.9.9.10'}
|
||||
).status_code == 200
|
||||
finally:
|
||||
app.config['ENDPOINTS_BOOTSTRAP_RATELIMIT_ENABLED'] = False
|
||||
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_MAX', None)
|
||||
app.config.pop('ENDPOINTS_BOOTSTRAP_RATELIMIT_WINDOW_SECONDS', None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base URL resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_command_falls_back_to_the_request_when_no_site_url_is_set(
|
||||
client, db, auth_headers):
|
||||
Setting.set('site_base_url', '', valuetype='string')
|
||||
data = _secret(client, auth_headers)
|
||||
assert data['command'] == _expected_command(data['secret'],
|
||||
base='https://localhost')
|
||||
assert data['scripturl'].startswith(
|
||||
'https://localhost/api/endpoints/siteagent/bootstrap.ps1?t=')
|
||||
assert data['scripturl'].endswith('&' + UI_LINK_QUERY)
|
||||
107
tests/test_plugins/test_endpoints_collect_log_denylist.py
Normal file
107
tests/test_plugins/test_endpoints_collect_log_denylist.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""collect_log sensitive-path denylist at enqueue.
|
||||
|
||||
The agent refuses answer files, its own install/state and key material. The
|
||||
server refuses the same set at enqueue so the operator gets a 400 naming the
|
||||
reason instead of a queued command that fails on the endpoint.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from plugins.endpoints.api.routes import _validate_log_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', [
|
||||
r'C:\Windows\Panther\setupact.log',
|
||||
r'c:/windows/panther/UnattendGC/setupact.log',
|
||||
r'%SystemRoot%\Panther\setupact.log',
|
||||
r'%windir%\System32\Sysprep\Panther\setupact.log',
|
||||
r'D:\WINDOWS\System32\sysprep\x.log',
|
||||
r'C:\Windows\\Panther\.\setupact.log',
|
||||
r'C:\ProgramData\ShopDB\state.json',
|
||||
r'%ProgramData%\shopdb\agent.log',
|
||||
r'C:\Program Files\ShopDB\EndpointAgent\agent.log',
|
||||
r'C:\PROGRA~1\ShopDB\EndpointAgent\agent.log',
|
||||
r'C:\temp\Unattend.xml',
|
||||
r'C:\temp\unattend-original.xml',
|
||||
r'C:\temp\AutoUnattend.xml',
|
||||
r'C:\app\appsettings.json',
|
||||
r'C:\app\AppSettings.Production.json',
|
||||
r'C:\certs\server.KEY',
|
||||
r'C:\certs\client.pfx',
|
||||
r'C:\certs\client.p12',
|
||||
])
|
||||
def test_sensitive_paths_are_refused_with_a_reason(path):
|
||||
error = _validate_log_path(path)
|
||||
assert error is not None
|
||||
assert 'not permitted' in error
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', [
|
||||
r'C:\Windows\Logs\CBS\CBS.log',
|
||||
r'C:\ProgramData\Vendor\app.log',
|
||||
r'C:\Program Files\Other\settings.json',
|
||||
r'C:\temp\notunattend.xml',
|
||||
])
|
||||
def test_ordinary_log_paths_still_pass(path):
|
||||
assert _validate_log_path(path) is None
|
||||
|
||||
|
||||
def _endpoint(db, hostname):
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname,
|
||||
agentversion='1.16.0.0'))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _admin(db, client, username='denylistadmin'):
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
role = Role.query.filter_by(rolename='admin').first()
|
||||
if role is None:
|
||||
role = Role(rolename='admin')
|
||||
db.session.add(role)
|
||||
db.session.commit()
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def test_enqueue_refuses_an_answer_file_and_queues_nothing(app, client, db):
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _endpoint(db, 'denylistbox')
|
||||
headers = _admin(db, client)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/commands',
|
||||
json={'action': 'collect_log',
|
||||
'target': r'%SystemRoot%\Panther\unattend.xml'},
|
||||
headers=headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'Panther' in message
|
||||
assert EndpointCommand.query.filter_by(assetid=assetid).count() == 0
|
||||
525
tests/test_plugins/test_endpoints_credential_admin_ui.py
Normal file
525
tests/test_plugins/test_endpoints_credential_admin_ui.py
Normal file
@@ -0,0 +1,525 @@
|
||||
"""Admin surfaces for per-PC endpoint credentials.
|
||||
|
||||
The fleet list, the PC detail status and the dashboard all describe the same
|
||||
fact - which credential a PC reports with - so they share one state vocabulary:
|
||||
enrolled, reenrollblocked, pending, notreporting, sharedkey. The list computes
|
||||
it in SQL so a filter survives pagination; PC detail redacts everything but the
|
||||
state for a reader below endpoints.fleet; the server certificate live check
|
||||
reads the host from settings, never from the request.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _pc(db, hostname, reported=True, serialnumber='SER'):
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, name=hostname, serialnumber=serialnumber,
|
||||
assettypeid=atype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
computer = Computer(assetid=asset.assetid, hostname=hostname)
|
||||
db.session.add(computer)
|
||||
db.session.add(EndpointState(
|
||||
assetid=asset.assetid, hostname=hostname, agentversion='1.20.0',
|
||||
lastreportedat=_now() if reported else None))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _credential(db, assetid, revoked=False, blocked=False, reason=None,
|
||||
revokedat=None, prefix='sdbe_abcdefg'):
|
||||
from plugins.endpoints.models import EndpointCredential
|
||||
|
||||
row = EndpointCredential(
|
||||
assetid=assetid, credentialhash=hashlib.sha256(
|
||||
str(assetid).encode()).hexdigest(),
|
||||
credentialprefix=prefix, enrolledvia='auto', issuedat=_now(),
|
||||
lastusedat=_now(),
|
||||
revokedat=(revokedat or _now()) if revoked else None,
|
||||
revokedby='admin' if revoked else None,
|
||||
reenrollblocked=blocked, reenrollblockedreason=reason)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _request(db, hostname, matchedassetid=None, reason='awaiting approval',
|
||||
status='pending'):
|
||||
from plugins.endpoints.models import EndpointEnrollRequest
|
||||
|
||||
row = EndpointEnrollRequest(
|
||||
hostname=hostname, serialnumber='SER', enrollnonce='n' * 40,
|
||||
status=status, reason=reason, matchedassetid=matchedassetid,
|
||||
requestedat=_now(), lastseenat=_now())
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _login(db, client, username, permissions=()):
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
role = Role(rolename=username + 'role')
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
for name in permissions:
|
||||
perm = Permission.query.filter_by(name=name).first()
|
||||
assert perm is not None, name
|
||||
role.permissions.append(perm)
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
return {'Authorization': 'Bearer ' + response.get_json()['data']['access_token']}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fleet(db):
|
||||
"""One PC in every credential state."""
|
||||
ids = {
|
||||
'ENROLLED': _pc(db, 'ENROLLED'),
|
||||
'BLOCKED': _pc(db, 'BLOCKED'),
|
||||
'PENDING': _pc(db, 'PENDING'),
|
||||
'SILENT': _pc(db, 'SILENT', reported=False),
|
||||
'SHARED': _pc(db, 'SHARED'),
|
||||
'REVOKED': _pc(db, 'REVOKED'),
|
||||
}
|
||||
_credential(db, ids['ENROLLED'])
|
||||
_credential(db, ids['BLOCKED'], revoked=True, blocked=True, reason='rollback')
|
||||
# A blocked PC keeps asking; its request must not turn it into pending.
|
||||
_request(db, 'BLOCKED', ids['BLOCKED'], reason='reenroll blocked')
|
||||
_request(db, 'pending')
|
||||
_credential(db, ids['REVOKED'], revoked=True)
|
||||
return ids
|
||||
|
||||
|
||||
EXPECTED = {
|
||||
'ENROLLED': 'enrolled',
|
||||
'BLOCKED': 'reenrollblocked',
|
||||
'PENDING': 'pending',
|
||||
'SILENT': 'notreporting',
|
||||
'SHARED': 'sharedkey',
|
||||
'REVOKED': 'sharedkey',
|
||||
}
|
||||
|
||||
|
||||
# -- fleet list ---------------------------------------------------------------
|
||||
|
||||
def test_fleet_list_carries_the_credential_state(client, db, auth_headers, fleet):
|
||||
response = client.get('/api/endpoints?perpage=100', headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
states = {row['hostname']: row['credentialstate']
|
||||
for row in response.get_json()['data']}
|
||||
assert states == EXPECTED
|
||||
|
||||
|
||||
@pytest.mark.parametrize('state', sorted(set(EXPECTED.values())))
|
||||
def test_fleet_list_filters_by_credential_state(client, db, auth_headers, fleet,
|
||||
state):
|
||||
response = client.get(f'/api/endpoints?credentialstate={state}',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
hosts = sorted(row['hostname'] for row in body['data'])
|
||||
assert hosts == sorted(h for h, s in EXPECTED.items() if s == state)
|
||||
assert body['meta']['pagination']['total'] == len(hosts)
|
||||
|
||||
|
||||
def test_credential_filter_keeps_paging(client, db, auth_headers, fleet):
|
||||
response = client.get('/api/endpoints?credentialstate=sharedkey&perpage=1'
|
||||
'&sort=hostname', headers=auth_headers)
|
||||
body = response.get_json()
|
||||
assert [row['hostname'] for row in body['data']] == ['REVOKED']
|
||||
assert body['meta']['pagination']['total'] == 2
|
||||
|
||||
|
||||
def test_fleet_list_state_costs_no_query_per_row(client, db, app, auth_headers,
|
||||
fleet):
|
||||
from sqlalchemy import event
|
||||
|
||||
statements = []
|
||||
|
||||
def record(_connection, _cursor, statement, *_args):
|
||||
if 'endpointcredentials' in statement or 'endpointenrollrequests' in statement:
|
||||
statements.append(statement)
|
||||
|
||||
engine = db.engine
|
||||
event.listen(engine, 'before_cursor_execute', record)
|
||||
try:
|
||||
client.get('/api/endpoints?perpage=100', headers=auth_headers)
|
||||
small = len(statements)
|
||||
for index in range(10):
|
||||
_pc(db, f'EXTRA-{index:02d}')
|
||||
statements.clear()
|
||||
client.get('/api/endpoints?perpage=100', headers=auth_headers)
|
||||
large = len(statements)
|
||||
finally:
|
||||
event.remove(engine, 'before_cursor_execute', record)
|
||||
assert small == large
|
||||
assert large <= 2
|
||||
|
||||
|
||||
def test_unknown_credential_filter_is_ignored_like_status(client, db,
|
||||
auth_headers, fleet):
|
||||
response = client.get('/api/endpoints?credentialstate=bogus',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.get_json()['data']) == len(EXPECTED)
|
||||
|
||||
|
||||
# -- PC detail ----------------------------------------------------------------
|
||||
|
||||
def test_endpoint_detail_credential_for_fleet_holders(client, db, auth_headers,
|
||||
fleet):
|
||||
data = client.get(f"/api/endpoints/{fleet['BLOCKED']}",
|
||||
headers=auth_headers).get_json()['data']
|
||||
credential = data['credential']
|
||||
assert credential['state'] == 'reenrollblocked'
|
||||
assert credential['statelabel'] == 'Re-enroll blocked'
|
||||
assert credential['credentialprefix'] == 'sdbe_abcdefg'
|
||||
assert credential['issuedat'] and credential['lastusedat']
|
||||
assert credential['enrolledvia'] == 'auto'
|
||||
assert credential['reenrollblocked'] is True
|
||||
assert credential['reenrollblockedreason'] == 'rollback'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('hostname', sorted(EXPECTED))
|
||||
def test_endpoint_detail_state_matches_the_list(client, db, auth_headers, fleet,
|
||||
hostname):
|
||||
data = client.get(f'/api/endpoints/{fleet[hostname]}',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert data['credential']['state'] == EXPECTED[hostname]
|
||||
|
||||
|
||||
def test_endpoint_detail_redacts_below_fleet(client, db, fleet):
|
||||
headers = _login(db, client, 'viewer', ['endpoints.view'])
|
||||
response = client.get(f"/api/endpoints/{fleet['ENROLLED']}", headers=headers)
|
||||
assert response.status_code == 200
|
||||
credential = response.get_json()['data']['credential']
|
||||
assert credential['state'] == 'enrolled'
|
||||
for key in ('credentialprefix', 'issuedat', 'lastusedat', 'revokedat',
|
||||
'enrolledvia', 'reenrollblockedreason'):
|
||||
assert credential[key] is None, key
|
||||
|
||||
panel = client.get(f"/api/endpoints/asset/{fleet['ENROLLED']}/status",
|
||||
headers=headers).get_json()['data']
|
||||
labels = {field['label']: field['value'] for field in panel['fields']}
|
||||
assert labels['Credential'] == 'Enrolled'
|
||||
assert 'Credential prefix' not in labels
|
||||
assert 'Credential issued' not in labels
|
||||
assert 'Credential last used' not in labels
|
||||
|
||||
|
||||
def test_guest_with_endpoints_view_sees_state_only(client, db, fleet):
|
||||
from shopdb.core.models import Permission, Role
|
||||
|
||||
Permission.seed()
|
||||
Role.seedguest()
|
||||
db.session.commit()
|
||||
guest = Role.guest()
|
||||
guest.permissions = Permission.query.filter_by(name='endpoints.view').all()
|
||||
db.session.commit()
|
||||
|
||||
response = client.get(f"/api/endpoints/asset/{fleet['BLOCKED']}/status")
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
labels = {field['label']: field['value'] for field in data['fields']}
|
||||
assert labels['Credential'] == 'Re-enroll blocked'
|
||||
assert 'Re-enroll block reason' not in labels
|
||||
assert data['credential']['credentialprefix'] is None
|
||||
assert data['credential']['reenrollblockedreason'] is None
|
||||
|
||||
|
||||
def test_status_panel_shows_credential_fields_to_fleet_holders(client, db,
|
||||
auth_headers,
|
||||
fleet):
|
||||
panel = client.get(f"/api/endpoints/asset/{fleet['BLOCKED']}/status",
|
||||
headers=auth_headers).get_json()['data']
|
||||
labels = {field['label']: field for field in panel['fields']}
|
||||
assert labels['Credential']['value'] == 'Re-enroll blocked'
|
||||
assert labels['Credential prefix']['value'] == 'sdbe_abcdefg...'
|
||||
assert labels['Credential prefix']['mono'] is True
|
||||
assert labels['Credential issued']['format'] == 'datetime'
|
||||
assert labels['Credential last used']['format'] == 'datetime'
|
||||
assert labels['Enrolled via']['value'] == 'auto'
|
||||
assert labels['Re-enroll block reason']['value'] == 'rollback'
|
||||
|
||||
|
||||
# -- server certificate live check -------------------------------------------
|
||||
|
||||
def _certificate_pem_and_der():
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'live.example.test')])
|
||||
now = datetime.now(timezone.utc)
|
||||
certificate = (x509.CertificateBuilder()
|
||||
.subject_name(name).issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1))
|
||||
.not_valid_after(now + timedelta(days=30))
|
||||
.sign(key, hashes.SHA256()))
|
||||
spki = key.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
return (certificate.public_bytes(serialization.Encoding.PEM),
|
||||
certificate.public_bytes(serialization.Encoding.DER),
|
||||
hashlib.sha256(spki).hexdigest(),
|
||||
certificate.fingerprint(hashes.SHA1()).hex().upper())
|
||||
|
||||
|
||||
class _FakeSocket:
|
||||
def __init__(self, der=None):
|
||||
self.der = der
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def getpeercert(self, binary_form=False):
|
||||
assert binary_form is True
|
||||
return self.der
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tls(monkeypatch, tmp_path):
|
||||
"""Replace the socket and ssl layer; record what the route connected to."""
|
||||
import socket
|
||||
import ssl
|
||||
|
||||
from plugins.endpoints.api import routes as endpoint_routes
|
||||
|
||||
certdir = tmp_path / 'certs'
|
||||
certdir.mkdir()
|
||||
monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: certdir)
|
||||
|
||||
state = {'der': None, 'connected': [], 'wrapped': [], 'error': None}
|
||||
|
||||
def create_connection(address, timeout=None):
|
||||
state['connected'].append((address, timeout))
|
||||
if state['error'] is not None:
|
||||
raise state['error']
|
||||
return _FakeSocket()
|
||||
|
||||
def wrap_socket(self, sock, server_hostname=None, **_kwargs):
|
||||
state['wrapped'].append((server_hostname, self.verify_mode,
|
||||
self.check_hostname))
|
||||
return _FakeSocket(state['der'])
|
||||
|
||||
monkeypatch.setattr(socket, 'create_connection', create_connection)
|
||||
monkeypatch.setattr(ssl.SSLContext, 'wrap_socket', wrap_socket)
|
||||
state['certdir'] = certdir
|
||||
return state
|
||||
|
||||
|
||||
LIVE_URL = '/api/endpoints/settings/server-cert/live'
|
||||
|
||||
|
||||
def test_live_check_unconfigured(client, db, auth_headers, tls):
|
||||
response = client.get(LIVE_URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data'] == {'configured': False}
|
||||
assert tls['connected'] == []
|
||||
|
||||
|
||||
def test_live_check_matches_the_file(client, db, auth_headers, tls):
|
||||
import ssl
|
||||
|
||||
pem, der, pin, sha1 = _certificate_pem_and_der()
|
||||
(tls['certdir'] / 'server.crt').write_bytes(pem)
|
||||
tls['der'] = der
|
||||
Setting.set('site_base_url', 'https://shopdb.example.test:8443/shopdb')
|
||||
db.session.commit()
|
||||
|
||||
response = client.get(LIVE_URL, headers={**auth_headers,
|
||||
'Host': 'attacker.example'})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data == {
|
||||
'configured': True,
|
||||
'url': 'https://shopdb.example.test:8443/shopdb',
|
||||
'presentedspkisha256': pin,
|
||||
'presentedsha1thumbprint': sha1,
|
||||
'matchesfile': True,
|
||||
'error': None,
|
||||
}
|
||||
# Host from the setting, never the request; 5 s; read-only handshake.
|
||||
assert tls['connected'] == [(('shopdb.example.test', 8443), 5)]
|
||||
assert tls['wrapped'] == [('shopdb.example.test', ssl.CERT_NONE, False)]
|
||||
|
||||
|
||||
def test_live_check_reports_a_different_certificate(client, db, auth_headers,
|
||||
tls):
|
||||
filepem, _der, filepin, _sha1 = _certificate_pem_and_der()
|
||||
_pem, liveder, livepin, _livesha1 = _certificate_pem_and_der()
|
||||
(tls['certdir'] / 'server.crt').write_bytes(filepem)
|
||||
tls['der'] = liveder
|
||||
Setting.set('site_base_url', 'https://shopdb.example.test')
|
||||
db.session.commit()
|
||||
|
||||
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
|
||||
assert data['presentedspkisha256'] == livepin != filepin
|
||||
assert data['matchesfile'] is False
|
||||
assert data['error'] is None
|
||||
assert tls['connected'][0][0] == ('shopdb.example.test', 443)
|
||||
|
||||
|
||||
def test_live_check_no_file_never_matches(client, db, auth_headers, tls):
|
||||
_pem, der, pin, _sha1 = _certificate_pem_and_der()
|
||||
tls['der'] = der
|
||||
Setting.set('site_base_url', 'https://10.0.0.5')
|
||||
db.session.commit()
|
||||
|
||||
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
|
||||
assert data['presentedspkisha256'] == pin
|
||||
assert data['matchesfile'] is False
|
||||
# No SNI for an address.
|
||||
assert tls['wrapped'][0][0] is None
|
||||
|
||||
|
||||
def test_live_check_connection_failure(client, db, auth_headers, tls):
|
||||
tls['error'] = OSError('Connection refused')
|
||||
Setting.set('site_base_url', 'https://shopdb.example.test')
|
||||
db.session.commit()
|
||||
|
||||
response = client.get(LIVE_URL, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert data['configured'] is True
|
||||
assert data['presentedspkisha256'] is None
|
||||
assert data['matchesfile'] is False
|
||||
assert 'Connection refused' in data['error']
|
||||
|
||||
|
||||
def test_live_check_refuses_plain_http(client, db, auth_headers, tls):
|
||||
Setting.set('site_base_url', 'http://shopdb.example.test')
|
||||
db.session.commit()
|
||||
|
||||
data = client.get(LIVE_URL, headers=auth_headers).get_json()['data']
|
||||
assert data['configured'] is True
|
||||
assert 'https' in data['error']
|
||||
assert tls['connected'] == []
|
||||
|
||||
|
||||
def test_live_check_allows_fleet_holders(client, db, tls):
|
||||
headers = _login(db, client, 'fleetreader', ['endpoints.fleet'])
|
||||
assert client.get(LIVE_URL, headers=headers).status_code == 200
|
||||
|
||||
|
||||
# -- dashboard ----------------------------------------------------------------
|
||||
|
||||
def test_pending_enrollment_attention_row(client, db, auth_headers, fleet):
|
||||
response = client.get('/api/endpoints/dashboard/enrollmentpending',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
# The blocked PC's own request is expected noise, not work.
|
||||
assert response.get_json()['data'] == [
|
||||
{'summary': '1 PC waiting for enrollment approval', 'pendingcount': 1}]
|
||||
|
||||
_request(db, 'ANOTHER')
|
||||
data = client.get('/api/endpoints/dashboard/enrollmentpending',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert data[0]['summary'] == '2 PCs waiting for enrollment approval'
|
||||
|
||||
|
||||
def test_pending_enrollment_attention_hides_when_nothing_waits(client, db,
|
||||
auth_headers):
|
||||
response = client.get('/api/endpoints/dashboard/enrollmentpending',
|
||||
headers=auth_headers)
|
||||
assert response.get_json()['data'] == []
|
||||
|
||||
|
||||
def test_reenroll_blocked_attention_after_seven_days(client, db, auth_headers):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
fresh = _pc(db, 'FRESH')
|
||||
stale = _pc(db, 'STALE')
|
||||
_credential(db, fresh, revoked=True, blocked=True,
|
||||
revokedat=_now() - timedelta(days=2))
|
||||
_credential(db, stale, revoked=True, blocked=True, reason='rollback',
|
||||
revokedat=_now() - timedelta(days=9))
|
||||
|
||||
data = client.get('/api/endpoints/dashboard/reenrollblocked',
|
||||
headers=auth_headers).get_json()['data']
|
||||
computerid = Computer.query.filter_by(assetid=stale).one().computerid
|
||||
assert data == [{'computerid': computerid, 'assetid': stale,
|
||||
'hostname': 'STALE', 'blockeddays': 9,
|
||||
'reason': 'rollback'}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('url', [
|
||||
'/api/endpoints/dashboard/enrollmentpending',
|
||||
'/api/endpoints/dashboard/reenrollblocked',
|
||||
LIVE_URL,
|
||||
])
|
||||
def test_new_reads_need_a_permission(client, db, member_headers, url):
|
||||
assert client.get(url, headers=member_headers).status_code == 403
|
||||
assert client.get(url).status_code == 401
|
||||
|
||||
|
||||
def test_dashboard_widgets_declare_the_enrollment_items():
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
widgets = {w['id']: w for w in EndpointsPlugin().get_dashboard_widgets()}
|
||||
pending = widgets['endpoints-enrollment-pending']
|
||||
assert pending['severity'] == 'warning'
|
||||
assert pending['endpoint'] == '/api/endpoints/dashboard/enrollmentpending'
|
||||
assert pending['map']['link'] == '/settings/endpoints/enrollment'
|
||||
blocked = widgets['endpoints-reenroll-blocked']
|
||||
assert blocked['severity'] == 'info'
|
||||
assert blocked['map']['link'] == '/pcs/{computerid}'
|
||||
for widget in (pending, blocked):
|
||||
assert widget['permission'] == 'endpoints.fleet'
|
||||
assert widget['empty'] == 'hide'
|
||||
|
||||
|
||||
# -- settings cards -----------------------------------------------------------
|
||||
|
||||
def test_settings_cards_describe_the_new_model_and_their_icons_render():
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
cards = {card['to']: card for card in EndpointsPlugin().get_settings_cards()}
|
||||
cert = cards['/settings/endpoints/server-cert']
|
||||
assert 'nginx' not in cert['description'].lower()
|
||||
assert 'IIS' in cert['description'] and 'Docker' in cert['description']
|
||||
assert 'shared' in cards['/settings/endpoints/agentkey']['description']
|
||||
enrollment = cards['/settings/endpoints/enrollment']
|
||||
assert enrollment['icon'] != cards['/settings/endpoints/agentkey']['icon']
|
||||
|
||||
catalog = os.path.join(os.path.dirname(__file__), '..', '..', 'frontend',
|
||||
'src', 'composables', 'settingsCatalog.js')
|
||||
with open(os.path.normpath(catalog), encoding='utf-8') as handle:
|
||||
source = handle.read()
|
||||
for card in cards.values():
|
||||
icon = card['icon']
|
||||
assert f"'{icon}':" in source or f" {icon}:" in source, icon
|
||||
935
tests/test_plugins/test_endpoints_credentials.py
Normal file
935
tests/test_plugins/test_endpoints_credentials.py
Normal file
@@ -0,0 +1,935 @@
|
||||
"""Per-PC endpoint credentials: enrollment, authentication and identity binding.
|
||||
|
||||
A PC enrolls once on the shared fleet key and gets its own secret. From then on
|
||||
the server derives the calling asset from that secret, so a request naming a
|
||||
different PC is refused (ASSET_MISMATCH), the shared key is refused for that PC
|
||||
(SHARED_KEY_REFUSED), and a revoked secret is refused outright
|
||||
(CREDENTIAL_INVALID) so the agent knows to enroll again.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, AuditLog, Setting
|
||||
|
||||
|
||||
COLLECTOR_KEY = 'testcollectorkey'
|
||||
ENROLL_URL = '/api/endpoints/enroll'
|
||||
NONCE = 'n' * 40
|
||||
OTHER_NONCE = 'm' * 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keyheaders(app):
|
||||
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||
return {'X-API-Key': COLLECTOR_KEY}
|
||||
|
||||
|
||||
def _computer(db, hostname, serialnumber='SER-1'):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
serialnumber=serialnumber, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _enroll_body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE):
|
||||
return {'hostname': hostname, 'serialnumber': serialnumber,
|
||||
'deviceid': None, 'enrollnonce': nonce, 'agentversion': '1.20.0'}
|
||||
|
||||
|
||||
def _enroll(client, headers, **kwargs):
|
||||
return client.post(ENROLL_URL, json=_enroll_body(**kwargs), headers=headers)
|
||||
|
||||
|
||||
def _credential(client, keyheaders, **kwargs):
|
||||
response = _enroll(client, keyheaders, **kwargs)
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
return response.get_json()['data']['credential']
|
||||
|
||||
|
||||
def _setting(db, key, value):
|
||||
Setting.set(key, value, valuetype='boolean', category='plugin.endpoints')
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _code(response):
|
||||
return response.get_json()['data']['error']['code']
|
||||
|
||||
|
||||
# -- enroll ------------------------------------------------------------------
|
||||
|
||||
def test_auto_enroll_issues_a_credential_once(client, db, app, keyheaders):
|
||||
from plugins.endpoints.models import EndpointCredential
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
response = _enroll(client, keyheaders)
|
||||
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data['status'] == 'enrolled'
|
||||
assert data['assetid'] == assetid
|
||||
assert data['credential'].startswith('sdbe_')
|
||||
assert len(data['credential']) == 48
|
||||
|
||||
row = EndpointCredential.query.filter_by(assetid=assetid).one()
|
||||
assert row.enrolledvia == 'auto'
|
||||
assert row.credentialprefix == data['credential'][:12]
|
||||
# Hash only, never the secret.
|
||||
assert data['credential'] not in (row.credentialhash, row.credentialprefix)
|
||||
|
||||
again = _enroll(client, keyheaders)
|
||||
assert again.status_code == 202
|
||||
assert again.get_json()['data']['reason'] == 'already enrolled'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('setup,body,reason', [
|
||||
('none', {}, 'unknown hostname'),
|
||||
('duplicate', {}, 'duplicate hostname'),
|
||||
('noserial', {}, 'no serial on record'),
|
||||
('serial', {'serialnumber': 'OTHER'}, 'serial mismatch'),
|
||||
('noauto', {}, 'awaiting approval'),
|
||||
])
|
||||
def test_each_pending_reason(client, db, app, keyheaders, setup, body, reason):
|
||||
if setup == 'duplicate':
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'bay-01')
|
||||
elif setup == 'noserial':
|
||||
_computer(db, 'BAY-01', serialnumber=None)
|
||||
elif setup in ('serial', 'noauto'):
|
||||
_computer(db, 'BAY-01')
|
||||
if setup == 'noauto':
|
||||
_setting(db, 'plugin.endpoints.autoenroll', False)
|
||||
|
||||
response = _enroll(client, keyheaders, **body)
|
||||
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data['status'] == 'pending'
|
||||
assert data['reason'] == reason
|
||||
assert data['requestid']
|
||||
|
||||
|
||||
def test_serial_compare_ignores_case_and_whitespace(client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01', serialnumber=' ser-1 ')
|
||||
assert _enroll(client, keyheaders, serialnumber='SER-1').status_code == 201
|
||||
|
||||
|
||||
def test_enroll_validates_the_nonce(client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
for nonce in ('short', 'x' * 129, 'bad nonce ' * 4):
|
||||
response = _enroll(client, keyheaders, nonce=nonce)
|
||||
assert response.status_code == 400, nonce
|
||||
|
||||
|
||||
@pytest.mark.parametrize('headers', [
|
||||
{},
|
||||
{'X-API-Key': 'not-the-key'},
|
||||
])
|
||||
def test_enroll_without_a_valid_key_records_a_pending_request(
|
||||
client, db, app, keyheaders, auth_headers, caplog, headers):
|
||||
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
response = _enroll(client, headers)
|
||||
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data['status'] == 'pending'
|
||||
assert data['reason'] == 'awaiting approval'
|
||||
assert EndpointCredential.query.filter_by(assetid=assetid).first() is None
|
||||
assert ('invalid API key' in caplog.text) == bool(headers)
|
||||
|
||||
# Retrying updates the same row; it never piles up rows.
|
||||
assert _enroll(client, headers).status_code == 202
|
||||
assert EndpointEnrollRequest.query.count() == 1
|
||||
|
||||
listing = client.get('/api/endpoints/enrollment',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [(r['requestid'], r['status']) for r in listing['requests']] == [
|
||||
(data['requestid'], 'pending')]
|
||||
assert listing['counts']['pending'] == 1
|
||||
|
||||
|
||||
def test_enroll_with_no_key_for_an_unknown_hostname_is_pending(
|
||||
client, db, app, keyheaders):
|
||||
response = _enroll(client, {}, hostname='NEW-PC')
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['reason'] == 'unknown hostname'
|
||||
|
||||
|
||||
def test_approved_request_issues_to_its_nonce_without_a_key(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
from plugins.endpoints.models import EndpointCredential
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
pending = _enroll(client, {})
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
approve = client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
assert approve.status_code == 200, approve.get_data(as_text=True)
|
||||
|
||||
wrong = _enroll(client, {}, nonce=OTHER_NONCE)
|
||||
assert wrong.status_code == 403
|
||||
assert _code(wrong) == 'ENROLL_NONCE_MISMATCH'
|
||||
|
||||
issued = _enroll(client, {})
|
||||
assert issued.status_code == 201, issued.get_data(as_text=True)
|
||||
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
|
||||
.enrolledvia == 'approved'
|
||||
|
||||
|
||||
def test_a_second_nonce_on_a_pending_request_stays_pending(client, db, app,
|
||||
keyheaders):
|
||||
from plugins.endpoints.models import EndpointEnrollRequest
|
||||
|
||||
_computer(db, 'BAY-01')
|
||||
_setting(db, 'plugin.endpoints.autoenroll', False)
|
||||
first = _enroll(client, keyheaders)
|
||||
assert first.status_code == 202
|
||||
|
||||
# Auto-enroll back on: a different installation still does not get through.
|
||||
_setting(db, 'plugin.endpoints.autoenroll', True)
|
||||
second = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
assert second.status_code == 202
|
||||
assert second.get_json()['data']['reason'] == 'nonce changed'
|
||||
row = db.session.get(EndpointEnrollRequest,
|
||||
first.get_json()['data']['requestid'])
|
||||
assert row.enrollnonce == NONCE
|
||||
|
||||
|
||||
def test_approved_request_issues_only_to_its_nonce(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
from plugins.endpoints.models import EndpointCredential
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
_setting(db, 'plugin.endpoints.autoenroll', False)
|
||||
pending = _enroll(client, keyheaders)
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
|
||||
approve = client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
assert approve.status_code == 200, approve.get_data(as_text=True)
|
||||
|
||||
wrong = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
assert wrong.status_code == 403
|
||||
assert _code(wrong) == 'ENROLL_NONCE_MISMATCH'
|
||||
|
||||
right = _enroll(client, keyheaders)
|
||||
assert right.status_code == 201, right.get_data(as_text=True)
|
||||
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
|
||||
.enrolledvia == 'approved'
|
||||
|
||||
|
||||
def test_shared_key_off_enroll_takes_no_auth_but_never_auto_enrolls(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
|
||||
|
||||
pending = client.post(ENROLL_URL, json=_enroll_body())
|
||||
assert pending.status_code == 202, pending.get_data(as_text=True)
|
||||
assert pending.get_json()['data']['reason'] == 'awaiting approval'
|
||||
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
issued = client.post(ENROLL_URL, json=_enroll_body())
|
||||
assert issued.status_code == 201
|
||||
|
||||
|
||||
def test_rejected_request_stays_rejected_for_the_same_nonce(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
_computer(db, 'BAY-01', serialnumber='SER-9')
|
||||
pending = _enroll(client, keyheaders)
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
client.post(f'/api/endpoints/enrollment/requests/{requestid}/reject',
|
||||
headers=auth_headers)
|
||||
|
||||
again = _enroll(client, keyheaders)
|
||||
assert again.status_code == 202
|
||||
assert again.get_json()['data']['reason'] == 'rejected'
|
||||
|
||||
|
||||
# -- agent auth --------------------------------------------------------------
|
||||
|
||||
def test_shared_key_is_refused_after_enrollment(client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
|
||||
refused = client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||
headers=keyheaders)
|
||||
assert refused.status_code == 401
|
||||
assert _code(refused) == 'SHARED_KEY_REFUSED'
|
||||
|
||||
ok = client.get('/api/endpoints/alerts',
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert ok.status_code == 200, ok.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_shared_key_still_works_for_a_pc_that_has_not_enrolled(
|
||||
client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02')
|
||||
_credential(client, keyheaders)
|
||||
|
||||
response = client.get('/api/endpoints/alerts?hostname=BAY-02',
|
||||
headers=keyheaders)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_shared_key_switched_off_is_refused(client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
|
||||
|
||||
response = client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||
headers=keyheaders)
|
||||
assert response.status_code == 401
|
||||
assert _code(response) == 'SHARED_KEY_REFUSED'
|
||||
|
||||
|
||||
def test_credential_is_invalid_after_revoke(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
|
||||
revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
headers=auth_headers)
|
||||
assert revoke.status_code == 200, revoke.get_data(as_text=True)
|
||||
|
||||
response = client.get('/api/endpoints/alerts',
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 401
|
||||
assert _code(response) == 'CREDENTIAL_INVALID'
|
||||
|
||||
# Revoked: the shared key is admitted again and the PC can re-enroll.
|
||||
assert client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||
headers=keyheaders).status_code == 200
|
||||
reissued = _credential(client, keyheaders)
|
||||
assert reissued != secret
|
||||
|
||||
|
||||
def test_revoke_with_block_keeps_the_pc_off_credentials(client, db, app,
|
||||
keyheaders,
|
||||
auth_headers):
|
||||
from plugins.endpoints.models import EndpointCredential
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
|
||||
revoke = client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
json={'blockreenroll': True, 'reason': 'rollback'},
|
||||
headers=auth_headers)
|
||||
assert revoke.status_code == 200, revoke.get_data(as_text=True)
|
||||
data = revoke.get_json()['data']
|
||||
assert data['revokedat'] is not None
|
||||
assert data['reenrollblocked'] is True
|
||||
assert data['reenrollblockedreason'] == 'rollback'
|
||||
|
||||
# The current agent asks again at once: refused, with the reason.
|
||||
again = _enroll(client, keyheaders)
|
||||
assert again.status_code == 202, again.get_data(as_text=True)
|
||||
assert again.get_json()['data']['reason'] == 'reenroll blocked'
|
||||
assert client.get('/api/endpoints/alerts',
|
||||
headers={'X-Endpoint-Credential': secret}).status_code == 401
|
||||
|
||||
# An older agent on the shared key keeps working.
|
||||
assert client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||
headers=keyheaders).status_code == 200
|
||||
|
||||
listing = client.get('/api/endpoints/enrollment',
|
||||
headers=auth_headers).get_json()['data']
|
||||
row = next(r for r in listing['computers'] if r['assetid'] == assetid)
|
||||
assert row['reenrollblocked'] is True
|
||||
assert row['reenrollblockedreason'] == 'rollback'
|
||||
assert row['state'] == 'revoked'
|
||||
|
||||
# Blocked also means an approval cannot hand one out.
|
||||
requestid = again.get_json()['data']['requestid']
|
||||
approve = client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
assert approve.status_code == 409
|
||||
|
||||
allow = client.post(
|
||||
f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
|
||||
headers=auth_headers)
|
||||
assert allow.status_code == 200, allow.get_data(as_text=True)
|
||||
assert allow.get_json()['data']['reenrollblocked'] is False
|
||||
row = EndpointCredential.query.filter_by(assetid=assetid).one()
|
||||
assert row.reenrollblockedreason is None
|
||||
|
||||
reissued = _credential(client, keyheaders)
|
||||
assert reissued != secret
|
||||
|
||||
|
||||
def test_blocked_pc_under_shared_key_off_is_refused_everything(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
_credential(client, keyheaders)
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
json={'blockreenroll': True}, headers=auth_headers)
|
||||
_setting(db, 'plugin.endpoints.sharedkeyallowed', False)
|
||||
|
||||
refused = client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||
headers=keyheaders)
|
||||
assert refused.status_code == 401
|
||||
assert _code(refused) == 'SHARED_KEY_REFUSED'
|
||||
|
||||
|
||||
def test_revoke_block_validation_and_revoked_rows(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
nocredential = _computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
url = f'/api/endpoints/enrollment/assets/{assetid}/revoke'
|
||||
_credential(client, keyheaders)
|
||||
|
||||
assert client.post(url, json={'blockreenroll': 'yes'},
|
||||
headers=auth_headers).status_code == 400
|
||||
assert client.post(url, json={'blockreenroll': True, 'reason': 'x' * 65},
|
||||
headers=auth_headers).status_code == 400
|
||||
|
||||
# Plain revoke, then block the already-revoked credential afterwards.
|
||||
assert client.post(url, headers=auth_headers).status_code == 200
|
||||
assert client.post(url, headers=auth_headers).status_code == 404
|
||||
blocked = client.post(url, json={'blockreenroll': True},
|
||||
headers=auth_headers)
|
||||
assert blocked.status_code == 200
|
||||
assert blocked.get_json()['data']['reenrollblocked'] is True
|
||||
|
||||
assert client.post(
|
||||
f'/api/endpoints/enrollment/assets/{nocredential}/revoke',
|
||||
json={'blockreenroll': True}, headers=auth_headers).status_code == 404
|
||||
assert client.post(
|
||||
f'/api/endpoints/enrollment/assets/{nocredential}/allow-reenroll',
|
||||
headers=auth_headers).status_code == 404
|
||||
|
||||
|
||||
def test_garbage_credential_is_invalid(client, db, app):
|
||||
response = client.get('/api/endpoints/commands/next',
|
||||
headers={'X-Endpoint-Credential': 'sdbe_nope'})
|
||||
assert response.status_code == 401
|
||||
assert _code(response) == 'CREDENTIAL_INVALID'
|
||||
|
||||
|
||||
def test_package_downloads_need_no_identity(client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
response = client.get('/api/endpoints/scriptbody/999',
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# -- ASSET_MISMATCH ----------------------------------------------------------
|
||||
|
||||
def test_commands_next_refuses_another_hostname(client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
secret = _credential(client, keyheaders)
|
||||
|
||||
response = client.get('/api/endpoints/commands/next?hostname=BAY-02',
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 403
|
||||
assert _code(response) == 'ASSET_MISMATCH'
|
||||
|
||||
|
||||
def test_commands_next_claims_for_the_credential_asset(client, db, app,
|
||||
keyheaders):
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
db.session.add(EndpointCommand(assetid=assetid, action='mtconnect_status',
|
||||
status=EndpointCommand.STATUS_PENDING))
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/api/endpoints/commands/next',
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['action'] == 'mtconnect_status'
|
||||
|
||||
|
||||
def test_command_result_refuses_another_assets_command(client, db, app,
|
||||
keyheaders):
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_computer(db, 'BAY-01')
|
||||
other = _computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
secret = _credential(client, keyheaders)
|
||||
command = EndpointCommand(assetid=other, action='mtconnect_status',
|
||||
status=EndpointCommand.STATUS_RUNNING)
|
||||
db.session.add(command)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post(
|
||||
f'/api/endpoints/commands/{command.endpointcommandid}/result',
|
||||
json={'succeeded': True},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 403
|
||||
assert _code(response) == 'ASSET_MISMATCH'
|
||||
|
||||
|
||||
def test_command_result_for_own_command_needs_no_hostname(client, db, app,
|
||||
keyheaders):
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
command = EndpointCommand(assetid=assetid, action='mtconnect_status',
|
||||
status=EndpointCommand.STATUS_RUNNING)
|
||||
db.session.add(command)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post(
|
||||
f'/api/endpoints/commands/{command.endpointcommandid}/result',
|
||||
json={'succeeded': True},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
|
||||
|
||||
def _fleet_alert(db, alertscope=None):
|
||||
from plugins.notifications.models import Notification, NotificationType
|
||||
|
||||
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
|
||||
isactive=True)
|
||||
db.session.add(ntype)
|
||||
db.session.flush()
|
||||
note = Notification(notificationtypeid=ntype.notificationtypeid,
|
||||
notification='Line 3 is down', alertfleet=True,
|
||||
alerttimeout=60, isactive=True, alertscope=alertscope)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
return note.notificationid
|
||||
|
||||
|
||||
def test_alert_ack_refuses_another_asset(client, db, app, keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
other = _computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
secret = _credential(client, keyheaders)
|
||||
notificationid = _fleet_alert(db)
|
||||
|
||||
response = client.post(
|
||||
f'/api/endpoints/alerts/{notificationid}/acknowledge',
|
||||
json={'assetid': other},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 403
|
||||
assert _code(response) == 'ASSET_MISMATCH'
|
||||
|
||||
byhostname = client.post(
|
||||
f'/api/endpoints/alerts/{notificationid}/acknowledge',
|
||||
json={'hostname': 'BAY-02'},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert byhostname.status_code == 403
|
||||
assert _code(byhostname) == 'ASSET_MISMATCH'
|
||||
|
||||
|
||||
def test_alert_ack_with_no_assetid_means_the_caller(client, db, app,
|
||||
keyheaders):
|
||||
from plugins.notifications.models import NotificationAcknowledgement
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
notificationid = _fleet_alert(db)
|
||||
|
||||
response = client.post(
|
||||
f'/api/endpoints/alerts/{notificationid}/acknowledge',
|
||||
json={'assetid': 0},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
assert NotificationAcknowledgement.query.filter_by(
|
||||
notificationid=notificationid, assetid=assetid).count() == 1
|
||||
|
||||
|
||||
def test_alert_ack_refuses_an_alert_not_scoped_to_the_caller(
|
||||
client, db, app, keyheaders):
|
||||
from plugins.notifications.models import NotificationAcknowledgement
|
||||
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
_computer(db, 'BAY-03', serialnumber='SER-3')
|
||||
secret = _credential(client, keyheaders)
|
||||
notificationid = _fleet_alert(db, alertscope={'hostname': ['BAY-02',
|
||||
'BAY-03']})
|
||||
url = f'/api/endpoints/alerts/{notificationid}/acknowledge'
|
||||
unknown = client.post('/api/endpoints/alerts/999999/acknowledge', json={},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
|
||||
bycredential = client.post(url, json={},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert bycredential.status_code == 404
|
||||
assert bycredential.get_json()['data']['error']['code'] == \
|
||||
unknown.get_json()['data']['error']['code']
|
||||
|
||||
bysharedkey = client.post(url, json={'hostname': 'BAY-03'},
|
||||
headers=keyheaders)
|
||||
assert bysharedkey.status_code == 201, bysharedkey.get_data(as_text=True)
|
||||
|
||||
_computer(db, 'BAY-04', serialnumber='SER-4')
|
||||
outside = client.post(url, json={'hostname': 'BAY-04'}, headers=keyheaders)
|
||||
assert outside.status_code == 404
|
||||
assert NotificationAcknowledgement.query.filter_by(
|
||||
notificationid=notificationid).count() == 1
|
||||
|
||||
|
||||
def test_collector_computers_refuses_another_hostname(client, db, app,
|
||||
keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
secret = _credential(client, keyheaders)
|
||||
headers = {'X-Endpoint-Credential': secret}
|
||||
|
||||
mismatch = client.post('/api/collector/computers',
|
||||
json={'hostname': 'BAY-02'}, headers=headers)
|
||||
assert mismatch.status_code == 403, mismatch.get_data(as_text=True)
|
||||
assert _code(mismatch) == 'ASSET_MISMATCH'
|
||||
|
||||
# A credential never creates a PC.
|
||||
unknown = client.post('/api/collector/computers',
|
||||
json={'hostname': 'NEW-BAY'}, headers=headers)
|
||||
assert unknown.status_code == 403
|
||||
assert _code(unknown) == 'ASSET_MISMATCH'
|
||||
|
||||
own = client.post('/api/collector/computers',
|
||||
json={'hostname': 'BAY-01'}, headers=headers)
|
||||
assert own.status_code == 200, own.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_collector_refuses_shared_key_for_an_enrolled_pc(client, db, app,
|
||||
keyheaders):
|
||||
_computer(db, 'BAY-01')
|
||||
_credential(client, keyheaders)
|
||||
|
||||
response = client.post('/api/collector/computers',
|
||||
json={'hostname': 'BAY-01'}, headers=keyheaders)
|
||||
assert response.status_code == 401
|
||||
assert _code(response) == 'SHARED_KEY_REFUSED'
|
||||
|
||||
|
||||
def test_collector_invalid_credential(client, db, app):
|
||||
response = client.post('/api/collector/printers',
|
||||
json={'hostname': 'BAY-01', 'queues': []},
|
||||
headers={'X-Endpoint-Credential': 'sdbe_' + 'a' * 43})
|
||||
assert response.status_code == 401
|
||||
assert _code(response) == 'CREDENTIAL_INVALID'
|
||||
|
||||
|
||||
def test_endpoints_collect_route_is_the_collector(client, db, app, keyheaders):
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
|
||||
response = client.post('/api/endpoints/collect',
|
||||
json={'hostname': 'BAY-01', 'agentversion': '1.20.0'},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
assert EndpointState.query.filter_by(assetid=assetid).one() \
|
||||
.agentversion == '1.20.0'
|
||||
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
mismatch = client.post('/api/endpoints/collect',
|
||||
json={'hostname': 'BAY-02'},
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert mismatch.status_code == 403
|
||||
assert _code(mismatch) == 'ASSET_MISMATCH'
|
||||
|
||||
|
||||
def test_contract_exports_answer_through_the_endpoints_plugin(client, db, app,
|
||||
keyheaders):
|
||||
from shopdb.api import agent_shared_key_refused, resolve_agent_credential
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
assert agent_shared_key_refused('BAY-01') is False
|
||||
secret = _credential(client, keyheaders)
|
||||
with app.test_request_context():
|
||||
assert resolve_agent_credential(secret) == assetid
|
||||
assert resolve_agent_credential('sdbe_' + 'z' * 43) is None
|
||||
assert agent_shared_key_refused('BAY-01') is True
|
||||
assert agent_shared_key_refused('UNKNOWN') is False
|
||||
|
||||
|
||||
# -- admin gating ------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize('method,url', [
|
||||
('get', '/api/endpoints/enrollment'),
|
||||
('get', '/api/endpoints/enrollment/migration'),
|
||||
('post', '/api/endpoints/enrollment/requests/1/approve'),
|
||||
('post', '/api/endpoints/enrollment/requests/1/reject'),
|
||||
('post', '/api/endpoints/enrollment/assets/1/revoke'),
|
||||
('post', '/api/endpoints/enrollment/assets/1/allow-reenroll'),
|
||||
('get', '/api/endpoints/settings/server-cert/download'),
|
||||
('put', '/api/endpoints/enrollment/settings'),
|
||||
('post', '/api/endpoints/enrollment/migration/wave'),
|
||||
])
|
||||
def test_member_without_a_role_is_forbidden(client, db, member_headers, method,
|
||||
url):
|
||||
response = getattr(client, method)(url, json={}, headers=member_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_enrollment_listing_and_settings(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
enrolled = _computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
_computer(db, 'BAY-03', serialnumber=None)
|
||||
_credential(client, keyheaders)
|
||||
_enroll(client, keyheaders, hostname='BAY-03', serialnumber='X')
|
||||
|
||||
response = client.get('/api/endpoints/enrollment', headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
body = response.get_json()
|
||||
data = body['data']
|
||||
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
|
||||
'revokeddeniessharedkey': False,
|
||||
'maxpendingenrollments': 500}
|
||||
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
|
||||
'pendingcomputers': 1, 'pending': 1}
|
||||
states = {row['hostname']: row['state'] for row in data['computers']}
|
||||
assert states == {'BAY-01': 'enrolled', 'BAY-02': 'notenrolled',
|
||||
'BAY-03': 'pending'}
|
||||
row = next(r for r in data['computers'] if r['assetid'] == enrolled)
|
||||
assert row['credentialprefix'].startswith('sdbe_')
|
||||
assert row['enrolledvia'] == 'auto'
|
||||
assert [r['reason'] for r in data['requests']
|
||||
if r['status'] == 'pending'] == ['no serial on record']
|
||||
assert body['meta']['pagination']['total'] == 3
|
||||
|
||||
filtered = client.get('/api/endpoints/enrollment?state=enrolled',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert [r['hostname'] for r in filtered['computers']] == ['BAY-01']
|
||||
|
||||
put = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'sharedkeyallowed': False}, headers=auth_headers)
|
||||
assert put.status_code == 200
|
||||
assert put.get_json()['data'] == {'sharedkeyallowed': False,
|
||||
'autoenroll': True,
|
||||
'revokeddeniessharedkey': False,
|
||||
'maxpendingenrollments': 500}
|
||||
bad = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'autoenroll': 'yes'}, headers=auth_headers)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_enrollment_pending_count_includes_unmatched_requests(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
_computer(db, 'BAY-01', serialnumber=None)
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
_enroll(client, keyheaders, hostname='BAY-01', serialnumber='X')
|
||||
_enroll(client, {}, hostname='NOT-IN-SHOPDB', serialnumber='Y')
|
||||
|
||||
data = client.get('/api/endpoints/enrollment',
|
||||
headers=auth_headers).get_json()['data']
|
||||
pendingrequests = [r for r in data['requests'] if r['status'] == 'pending']
|
||||
assert len(pendingrequests) == 2
|
||||
assert data['counts'] == {'enrolled': 0, 'notenrolled': 1,
|
||||
'pendingcomputers': 1, 'pending': 2}
|
||||
states = [row['state'] for row in data['computers']]
|
||||
assert states.count('pending') == data['counts']['pendingcomputers']
|
||||
|
||||
|
||||
# -- approve binds the hostname ----------------------------------------------
|
||||
#
|
||||
# A one-off PC installed by the bootstrap installer is pending for `unknown
|
||||
# hostname` by definition. Approving used to set matchedassetid only, so the
|
||||
# hostname the agent sends resolved to nothing, bind_hostname compared that with
|
||||
# the credential's asset and answered 403 ASSET_MISMATCH on every call - the PC
|
||||
# enrolled and then never worked until someone typed the hostname in by hand.
|
||||
|
||||
def _forget_hostname(db, assetid):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
Computer.query.filter_by(assetid=assetid).one().hostname = None
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _hostname(assetid):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
return Computer.query.filter_by(assetid=assetid).one().hostname
|
||||
|
||||
|
||||
def _approve(client, auth_headers, requestid, assetid, **body):
|
||||
return client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json=dict(assetid=assetid, **body), headers=auth_headers)
|
||||
|
||||
|
||||
def _computer_audits():
|
||||
return AuditLog.query.filter_by(entitytype='Computer').all()
|
||||
|
||||
|
||||
def test_approve_records_the_hostname_on_a_computer_that_has_none(
|
||||
client, db, app, auth_headers):
|
||||
assetid = _computer(db, 'PLACEHOLDER', serialnumber='SER-9')
|
||||
_forget_hostname(db, assetid)
|
||||
|
||||
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
|
||||
assert pending.get_json()['data']['reason'] == 'unknown hostname'
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
|
||||
approved = _approve(client, auth_headers, requestid, assetid)
|
||||
assert approved.status_code == 200, approved.get_data(as_text=True)
|
||||
assert approved.get_json()['data']['hostnameresult'] == 'set'
|
||||
assert _hostname(assetid) == 'LOANER-07'
|
||||
audit = _computer_audits()
|
||||
assert len(audit) == 1
|
||||
assert audit[0].changes['hostname'] == {'old': None, 'new': 'LOANER-07'}
|
||||
|
||||
# The whole point: the PC collects its credential and is then authorized.
|
||||
secret = _credential(client, {}, hostname='LOANER-07',
|
||||
serialnumber='SER-9')
|
||||
works = client.get('/api/endpoints/alerts?hostname=LOANER-07',
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert works.status_code == 200, works.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_approve_refuses_to_rename_a_computer_without_being_told_to(
|
||||
client, db, app, auth_headers):
|
||||
from plugins.endpoints.models import EndpointEnrollRequest
|
||||
|
||||
assetid = _computer(db, 'BAY-09', serialnumber='SER-9')
|
||||
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
|
||||
refused = _approve(client, auth_headers, requestid, assetid)
|
||||
assert refused.status_code == 409
|
||||
error = refused.get_json()['data']['error']
|
||||
assert 'BAY-09' in error['message'] and 'LOANER-07' in error['message']
|
||||
assert error['details']['hostnameconflict'] is True
|
||||
|
||||
# Nothing moved: not the hostname, not the request.
|
||||
assert _hostname(assetid) == 'BAY-09'
|
||||
assert _computer_audits() == []
|
||||
row = db.session.get(EndpointEnrollRequest, requestid)
|
||||
assert row.status == EndpointEnrollRequest.STATUS_PENDING
|
||||
assert row.matchedassetid is None
|
||||
|
||||
|
||||
def test_approve_with_sethostname_overwrites_and_audits_the_rename(
|
||||
client, db, app, auth_headers):
|
||||
assetid = _computer(db, 'BAY-09', serialnumber='SER-9')
|
||||
pending = _enroll(client, {}, hostname='LOANER-07', serialnumber='SER-9')
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
|
||||
approved = _approve(client, auth_headers, requestid, assetid,
|
||||
sethostname=True)
|
||||
assert approved.status_code == 200, approved.get_data(as_text=True)
|
||||
assert approved.get_json()['data']['hostnameresult'] == 'overwritten'
|
||||
assert _hostname(assetid) == 'LOANER-07'
|
||||
audit = _computer_audits()
|
||||
assert len(audit) == 1
|
||||
assert audit[0].changes['hostname'] == {'old': 'BAY-09',
|
||||
'new': 'LOANER-07'}
|
||||
assert audit[0].username == 'testadmin'
|
||||
|
||||
assert _approve(client, auth_headers, requestid, assetid,
|
||||
sethostname='yes').status_code == 400
|
||||
|
||||
|
||||
def test_approve_onto_a_matching_hostname_changes_nothing(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
_setting(db, 'plugin.endpoints.autoenroll', False)
|
||||
pending = _enroll(client, keyheaders)
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
|
||||
approved = _approve(client, auth_headers, requestid, assetid)
|
||||
assert approved.status_code == 200, approved.get_data(as_text=True)
|
||||
assert approved.get_json()['data']['hostnameresult'] == 'unchanged'
|
||||
assert _hostname(assetid) == 'BAY-01'
|
||||
assert _computer_audits() == []
|
||||
|
||||
|
||||
def test_a_hostname_on_no_computer_record_is_refused_but_named_as_such(
|
||||
client, db, app, keyheaders):
|
||||
"""Same code and status, an honest message: the credential is good and the
|
||||
hostname is simply on no record, which is a server-side data problem, not a
|
||||
PC claiming to be another one."""
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
secret = _credential(client, keyheaders)
|
||||
_forget_hostname(db, assetid)
|
||||
|
||||
response = client.get('/api/endpoints/alerts?hostname=BAY-01',
|
||||
headers={'X-Endpoint-Credential': secret})
|
||||
assert response.status_code == 403
|
||||
assert _code(response) == 'ASSET_MISMATCH'
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'BAY-01' in message
|
||||
assert 'no computer record' in message.lower()
|
||||
|
||||
|
||||
def test_approve_refuses_an_enrolled_computer(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
_credential(client, keyheaders)
|
||||
pending = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
requestid = pending.get_json()['data']['requestid']
|
||||
|
||||
response = client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
# -- server certificate pin --------------------------------------------------
|
||||
|
||||
def test_server_cert_reports_the_spki_pin(client, db, auth_headers, tmp_path,
|
||||
monkeypatch):
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from plugins.endpoints.api import routes as endpoint_routes
|
||||
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'pin.example.test')])
|
||||
now = datetime.now(timezone.utc)
|
||||
certificate = (x509.CertificateBuilder()
|
||||
.subject_name(name).issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1))
|
||||
.not_valid_after(now + timedelta(days=30))
|
||||
.sign(key, hashes.SHA256()))
|
||||
certdir = tmp_path / 'certs'
|
||||
certdir.mkdir()
|
||||
(certdir / 'server.crt').write_bytes(
|
||||
certificate.public_bytes(serialization.Encoding.PEM))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
if hasattr(endpoint_routes, '_server_cert_dir'):
|
||||
monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: certdir)
|
||||
|
||||
expected = hashlib.sha256(key.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)).hexdigest()
|
||||
|
||||
response = client.get('/api/endpoints/settings/server-cert',
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data['spkisha256'] == expected
|
||||
sha1 = certificate.fingerprint(hashes.SHA1()).hex().upper()
|
||||
assert data['sha1thumbprint'] == sha1
|
||||
assert ':' not in data['sha1thumbprint']
|
||||
|
||||
download = client.get('/api/endpoints/settings/server-cert/download',
|
||||
headers=auth_headers)
|
||||
assert download.status_code == 200
|
||||
assert download.mimetype == 'application/pkix-cert'
|
||||
assert 'attachment' in download.headers['Content-Disposition']
|
||||
assert '.cer' in download.headers['Content-Disposition']
|
||||
assert download.data == certificate.public_bytes(serialization.Encoding.DER)
|
||||
assert b'PRIVATE' not in download.data
|
||||
140
tests/test_plugins/test_endpoints_credentials_migration.py
Normal file
140
tests/test_plugins/test_endpoints_credentials_migration.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Migration 0023: per-PC credential tables, and the client certificate cleanup.
|
||||
|
||||
Two starting points matter. A database that ran the 2026-09-14 builds of
|
||||
0020-0022 carries app_fingerprint and five certificate columns on assets; a
|
||||
fresh one carries neither. Both must reach the same schema, twice over.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
|
||||
_VERSIONS = (Path(__file__).resolve().parent.parent.parent
|
||||
/ 'plugins' / 'endpoints' / 'migrations' / 'versions')
|
||||
_CHAIN = ('0020_app_delta_fingerprints.py', '0021_client_certificates.py',
|
||||
'0022_client_cert_revocation.py', '0023_endpoint_credentials.py')
|
||||
_CERT_COLUMNS = {'client_cert_fingerprint', 'cert_issued_at', 'cert_expires_at',
|
||||
'cert_revoked_at', 'cert_revocation_reason'}
|
||||
|
||||
|
||||
def _load(filename):
|
||||
path = _VERSIONS / filename
|
||||
spec = importlib.util.spec_from_file_location('endpoints_mig_' + path.stem,
|
||||
str(path))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run(connection, step='upgrade', files=_CHAIN):
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
for filename in files:
|
||||
getattr(_load(filename), step)()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _base_schema(connection, legacy):
|
||||
certcolumns = ''
|
||||
if legacy:
|
||||
certcolumns = (', client_cert_fingerprint VARCHAR(64), '
|
||||
'cert_issued_at DATETIME, cert_expires_at DATETIME, '
|
||||
'cert_revoked_at DATETIME, '
|
||||
'cert_revocation_reason VARCHAR(255)')
|
||||
connection.execute(text(
|
||||
'CREATE TABLE assets (assetid INTEGER PRIMARY KEY, '
|
||||
'assetnumber VARCHAR(50)' + certcolumns + ')'))
|
||||
fingerprint = ', app_fingerprint VARCHAR(64)' if legacy else ''
|
||||
connection.execute(text(
|
||||
'CREATE TABLE endpointapplications (endpointapplicationid INTEGER '
|
||||
'PRIMARY KEY, assetid INTEGER, displayname VARCHAR(255)'
|
||||
+ fingerprint + ')'))
|
||||
if legacy:
|
||||
connection.execute(text(
|
||||
'CREATE INDEX idx_assets_cert_fingerprint ON assets '
|
||||
'(client_cert_fingerprint)'))
|
||||
connection.execute(text(
|
||||
'CREATE UNIQUE INDEX idx_endpointapplications_fingerprint ON '
|
||||
'endpointapplications (assetid, app_fingerprint)'))
|
||||
connection.execute(text(
|
||||
"INSERT INTO endpointapplications VALUES (1, 7, 'App', '"
|
||||
+ 'a' * 64 + "')"))
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _assert_head_schema(connection):
|
||||
inspector = inspect(connection)
|
||||
assetcolumns = {c['name'] for c in inspector.get_columns('assets')}
|
||||
assert not assetcolumns & _CERT_COLUMNS
|
||||
appcolumns = {c['name'] for c in inspector.get_columns('endpointapplications')}
|
||||
assert 'appfingerprint' in appcolumns
|
||||
assert 'app_fingerprint' not in appcolumns
|
||||
indexed = {tuple(ix['column_names']): ix['unique'] for ix in
|
||||
inspector.get_indexes('endpointapplications')}
|
||||
assert indexed.get(('assetid', 'appfingerprint')) in (True, 1)
|
||||
|
||||
for table in ('endpointcredentials', 'endpointenrollrequests',
|
||||
'endpointmigrationwaves'):
|
||||
assert inspector.has_table(table), table
|
||||
credentialcolumns = {c['name'] for c in inspector.get_columns('endpointcredentials')}
|
||||
assert credentialcolumns == {
|
||||
'credentialid', 'assetid', 'credentialhash', 'credentialprefix',
|
||||
'enrolledvia', 'issuedat', 'lastusedat', 'revokedat', 'revokedby',
|
||||
'reenrollblocked', 'reenrollblockedreason'}
|
||||
requestindexes = [tuple(ix['column_names']) for ix in
|
||||
inspector.get_indexes('endpointenrollrequests')]
|
||||
assert requestindexes.count(('hostname', 'serialnumber')) == 1
|
||||
|
||||
|
||||
def test_upgrade_from_the_client_certificate_builds():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_base_schema(connection, legacy=True)
|
||||
_run(connection)
|
||||
_assert_head_schema(connection)
|
||||
# Data survives the rename.
|
||||
assert connection.execute(text(
|
||||
'SELECT appfingerprint FROM endpointapplications')).scalar() == 'a' * 64
|
||||
|
||||
_run(connection)
|
||||
_assert_head_schema(connection)
|
||||
|
||||
|
||||
def test_upgrade_on_a_fresh_database():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_base_schema(connection, legacy=False)
|
||||
_run(connection)
|
||||
_assert_head_schema(connection)
|
||||
_run(connection)
|
||||
_assert_head_schema(connection)
|
||||
|
||||
|
||||
def test_retired_certificate_revisions_are_no_ops():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_base_schema(connection, legacy=False)
|
||||
_run(connection, files=_CHAIN[1:3])
|
||||
columns = {c['name'] for c in inspect(connection).get_columns('assets')}
|
||||
assert not columns & _CERT_COLUMNS
|
||||
|
||||
|
||||
def test_downgrade_drops_the_credential_tables():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_base_schema(connection, legacy=False)
|
||||
_run(connection)
|
||||
_run(connection, step='downgrade', files=_CHAIN[3:])
|
||||
inspector = inspect(connection)
|
||||
for table in ('endpointcredentials', 'endpointenrollrequests',
|
||||
'endpointmigrationwaves'):
|
||||
assert not inspector.has_table(table), table
|
||||
_run(connection, files=_CHAIN[3:])
|
||||
_assert_head_schema(connection)
|
||||
168
tests/test_plugins/test_endpoints_dellupdates.py
Normal file
168
tests/test_plugins/test_endpoints_dellupdates.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Pending Dell Command | Update reporting: ingest and status-card exposure.
|
||||
|
||||
The agent's shopdb-dellupdates utility reads the log the \\ShopDB-DellCommandScan
|
||||
scheduled task writes and reports a short summary on the normal telemetry
|
||||
check-in. These tests cover the ingest write path and the three-state rendering
|
||||
on the status panel, which is the part most likely to be broken by a well-meaning
|
||||
simplification: "never scanned" must NOT render as "No".
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
|
||||
STATUS_URL = '/api/endpoints/asset/{}/status'
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
"""Create a computer Asset + Computer row; return the asset."""
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
|
||||
ctype = ComputerType.query.filter_by(computertype='CMM PC').first()
|
||||
if not ctype:
|
||||
ctype = ComputerType(computertype='CMM PC')
|
||||
db.session.add(ctype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname,
|
||||
computertypeid=ctype.computertypeid))
|
||||
db.session.flush()
|
||||
return asset
|
||||
|
||||
|
||||
def _checkin(db, hostname, payload):
|
||||
"""Run a check-in for hostname with the extra payload keys merged in."""
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
body = {'hostname': hostname}
|
||||
body.update(payload)
|
||||
EndpointsPlugin().apply_collector_payload(body)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _status_fields(client, asset, auth_headers):
|
||||
response = client.get(STATUS_URL.format(asset.assetid), headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
return {f['label']: f['value']
|
||||
for f in response.get_json()['data']['fields']}
|
||||
|
||||
|
||||
def test_checkin_persists_the_dell_summary(client, db, app, auth_headers):
|
||||
"""A reported dellupdates payload is stored on the endpoint state."""
|
||||
from plugins.endpoints.models.endpoint import EndpointState
|
||||
|
||||
_computer(db, 'DELL01')
|
||||
_checkin(db, 'DELL01', {'dellupdates': {
|
||||
'status': 'ok', 'count': 1, 'truncated': False,
|
||||
'updates': [{'name': 'Dell BIOS', 'severity': 'Urgent',
|
||||
'version': '1.2.3'}],
|
||||
}})
|
||||
|
||||
state = EndpointState.query.filter_by(
|
||||
assetid=Asset.query.filter_by(assetnumber='DELL01').first().assetid
|
||||
).first()
|
||||
stored = json.loads(state.dellupdatesjson)
|
||||
assert stored['count'] == 1
|
||||
assert stored['updates'][0]['name'] == 'Dell BIOS'
|
||||
|
||||
|
||||
def test_pending_updates_show_yes_with_a_count(client, db, app, auth_headers):
|
||||
"""A scan with pending items renders Yes plus how many."""
|
||||
asset = _computer(db, 'DELL02')
|
||||
_checkin(db, 'DELL02', {'dellupdates': {
|
||||
'status': 'ok', 'count': 3, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Yes (3)'
|
||||
|
||||
|
||||
def test_a_clean_scan_shows_no(client, db, app, auth_headers):
|
||||
"""A scan that found nothing renders No, not a hidden row."""
|
||||
asset = _computer(db, 'DELL03')
|
||||
_checkin(db, 'DELL03', {'dellupdates': {
|
||||
'status': 'ok', 'count': 0, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'No'
|
||||
|
||||
|
||||
def test_a_never_scanned_endpoint_says_so(client, db, app, auth_headers):
|
||||
"""Never scanned renders as its own visible state, not as No and not hidden.
|
||||
|
||||
Two failure modes are being guarded at once: showing 'No' would claim the
|
||||
machine is current when nobody ever checked, and hiding the row entirely
|
||||
reads to an operator as a broken panel.
|
||||
"""
|
||||
asset = _computer(db, 'DELL04')
|
||||
_checkin(db, 'DELL04', {})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Never scanned'
|
||||
|
||||
|
||||
def test_an_unknown_status_says_never_scanned(client, db, app, auth_headers):
|
||||
"""The utility's own 'never scanned' answer renders as never scanned."""
|
||||
asset = _computer(db, 'DELL05')
|
||||
_checkin(db, 'DELL05', {'dellupdates': {
|
||||
'status': 'unknown', 'count': 0, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Never scanned'
|
||||
|
||||
|
||||
def test_a_failed_scan_read_is_distinct_from_never_scanned(client, db, app,
|
||||
auth_headers):
|
||||
"""An unreadable scan log is its own state, not silence and not clean."""
|
||||
asset = _computer(db, 'DELL06')
|
||||
_checkin(db, 'DELL06', {'dellupdates': {
|
||||
'status': 'error', 'count': 0, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Unknown (scan unreadable)'
|
||||
|
||||
|
||||
def test_a_malformed_blob_does_not_break_the_panel(client, db, app, auth_headers):
|
||||
"""A corrupt cached payload degrades to the unknown state, not a 500."""
|
||||
from plugins.endpoints.models.endpoint import EndpointState
|
||||
|
||||
asset = _computer(db, 'DELL07')
|
||||
_checkin(db, 'DELL07', {})
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
|
||||
state.dellupdatesjson = 'not json at all'
|
||||
db.session.commit()
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Never scanned'
|
||||
|
||||
|
||||
def test_an_omitted_key_preserves_the_last_result(client, db, app, auth_headers):
|
||||
"""A check-in without the key does not erase a previously reported scan.
|
||||
|
||||
The agent omits dellupdates entirely when the collector utility is absent,
|
||||
so overwriting to null would discard a still-valid result.
|
||||
"""
|
||||
asset = _computer(db, 'DELL08')
|
||||
_checkin(db, 'DELL08', {'dellupdates': {
|
||||
'status': 'ok', 'count': 2, 'truncated': False, 'updates': [],
|
||||
}})
|
||||
_checkin(db, 'DELL08', {})
|
||||
|
||||
fields = _status_fields(client, asset, auth_headers)
|
||||
assert fields['Pending Dell updates'] == 'Yes (2)'
|
||||
108
tests/test_plugins/test_endpoints_disk_health.py
Normal file
108
tests/test_plugins/test_endpoints_disk_health.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Disk health rows: a dying drive must never render as a healthy one.
|
||||
|
||||
The agent reports drive health from two sources because neither covers the
|
||||
fleet. MSFT_PhysicalDisk is the only one that reports NVMe - verified on a
|
||||
two-NVMe box where the classic ATA SMART class returned zero instances while
|
||||
both drives were present and healthy. Win32_DiskDrive covers ATA/SATA and hosts
|
||||
where the Storage namespace is missing.
|
||||
|
||||
That means the panel can receive the same physical drive twice, from sources
|
||||
that may disagree, plus rows where health could not be read at all. Every test
|
||||
here pins a way this card could start showing a failing or unreadable drive as
|
||||
fine, which is the only failure mode that matters: nobody investigates a disk
|
||||
that looks healthy.
|
||||
"""
|
||||
|
||||
from plugins.endpoints.api.routes import _diskhealth_rows
|
||||
|
||||
|
||||
def _drive(deviceid, predictfailure, healthstatus='Healthy',
|
||||
source='physicaldisk', model='Samsung SSD'):
|
||||
return {
|
||||
'deviceid': deviceid,
|
||||
'model': model,
|
||||
'mediatype': 'SSD',
|
||||
'bustype': 'NVMe',
|
||||
'healthstatus': healthstatus,
|
||||
'predictfailure': predictfailure,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
|
||||
def test_a_failing_drive_sorts_above_healthy_ones():
|
||||
"""On a multi-disk box the bad drive must not be below the fold."""
|
||||
rows = _diskhealth_rows([
|
||||
_drive('0', False),
|
||||
_drive('1', True, healthstatus='Unhealthy'),
|
||||
_drive('2', False),
|
||||
])
|
||||
|
||||
assert [row['deviceid'] for row in rows] == ['1', '0', '2']
|
||||
assert rows[0]['verdict'] == 'Failure predicted'
|
||||
|
||||
|
||||
def test_a_drive_that_did_not_answer_reads_as_unknown_not_ok():
|
||||
"""Null predictfailure is an unread drive, not a healthy one."""
|
||||
rows = _diskhealth_rows([_drive('0', None, healthstatus='')])
|
||||
|
||||
assert rows[0]['verdict'] == 'Unknown'
|
||||
assert rows[0]['healthstatus'] == 'Unknown'
|
||||
|
||||
|
||||
def test_unknown_outranks_ok_so_silence_is_not_buried_under_health():
|
||||
"""A drive that stopped answering is a weaker claim than one saying OK."""
|
||||
rows = _diskhealth_rows([
|
||||
_drive('0', False),
|
||||
_drive('1', None),
|
||||
])
|
||||
|
||||
assert [row['verdict'] for row in rows] == ['Unknown', 'OK']
|
||||
|
||||
|
||||
def test_both_sources_survive_for_the_same_hardware():
|
||||
"""Two disagreeing views of one drive is the signal, not noise.
|
||||
|
||||
Collapsing them would hide the case worth looking at: one source calling a
|
||||
drive healthy while the other predicts failure.
|
||||
"""
|
||||
rows = _diskhealth_rows([
|
||||
_drive('0', False, source='physicaldisk'),
|
||||
_drive('0', True, healthstatus='Pred Fail', source='diskdrive'),
|
||||
])
|
||||
|
||||
assert len(rows) == 2
|
||||
assert {row['source'] for row in rows} == {'physicaldisk', 'diskdrive'}
|
||||
assert rows[0]['verdict'] == 'Failure predicted'
|
||||
|
||||
|
||||
def test_input_order_does_not_change_which_drive_is_shown_first():
|
||||
"""The verdict ranking must decide the order, not arrival order."""
|
||||
healthy = _drive('0', False)
|
||||
failing = _drive('1', True)
|
||||
|
||||
forward = _diskhealth_rows([healthy, failing])
|
||||
backward = _diskhealth_rows([failing, healthy])
|
||||
|
||||
assert forward == backward
|
||||
assert forward[0]['deviceid'] == '1'
|
||||
|
||||
|
||||
def test_an_nvme_drive_is_reported_rather_than_dropped():
|
||||
"""The whole reason for the second source: ATA SMART returns nothing here."""
|
||||
rows = _diskhealth_rows([_drive('0', False, source='physicaldisk')])
|
||||
|
||||
assert rows[0]['bustype'] == 'NVMe'
|
||||
assert rows[0]['verdict'] == 'OK'
|
||||
|
||||
|
||||
def test_a_malformed_payload_does_not_take_the_panel_down():
|
||||
rows = _diskhealth_rows([_drive('0', False), 'not a dict', None])
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['deviceid'] == '0'
|
||||
|
||||
|
||||
def test_a_missing_or_wrong_shaped_payload_is_an_empty_panel():
|
||||
assert _diskhealth_rows(None) == []
|
||||
assert _diskhealth_rows({}) == []
|
||||
assert _diskhealth_rows([]) == []
|
||||
220
tests/test_plugins/test_endpoints_enroll_limits.py
Normal file
220
tests/test_plugins/test_endpoints_enroll_limits.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""The two bounds on POST /api/endpoints/enroll.
|
||||
|
||||
The route takes no auth on purpose - a PC installed without the shared key must
|
||||
still be able to reach an admin - and it writes a row for every new
|
||||
(hostname, serialnumber) pair. That is an unauthenticated caller writing to the
|
||||
database in a loop, and it is also a denial of the enrollment workflow: junk
|
||||
pending rows bury the real requests an admin has to approve.
|
||||
|
||||
Two bounds, and they answer different questions. The per-IP fixed-window limiter
|
||||
bounds how FAST; the settings-backed cap bounds how MANY rows exist. Neither may
|
||||
touch a PC already in the workflow, so the cap refuses only a hostname and
|
||||
serial never seen before and an existing pending or approved row still answers
|
||||
exactly as it did.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
|
||||
from plugins.endpoints.api.enrollment import PENDING_CAP_SETTING
|
||||
from plugins.endpoints.models import EndpointEnrollRequest
|
||||
|
||||
|
||||
ENROLL_URL = '/api/endpoints/enroll'
|
||||
NONCE = 'n' * 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def limits(app):
|
||||
"""Both bounds on, at their shipped defaults, restored afterwards.
|
||||
|
||||
The limiter counts in the cache extension, which is process-wide and
|
||||
outlives a test. Any other test in the same worker that posts to /enroll
|
||||
spends budget in the same window and IP bucket, so a count-exactly test has
|
||||
to start from a clean counter - and leave one behind, since it deliberately
|
||||
fills a bucket. Which tests share a worker depends on the xdist
|
||||
distribution, so without this the failure is intermittent and looks
|
||||
unrelated to whatever changed.
|
||||
"""
|
||||
from shopdb.api import cache
|
||||
|
||||
previous = {key: app.config.get(key) for key in (
|
||||
'ENDPOINTS_ENROLL_RATELIMIT_ENABLED',
|
||||
'ENDPOINTS_ENROLL_RATELIMIT_MAX',
|
||||
'ENDPOINTS_ENROLL_RATELIMIT_WINDOW_SECONDS')}
|
||||
app.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = True
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
yield app
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = value
|
||||
|
||||
|
||||
def _computer(db, hostname, serialnumber):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
serialnumber=serialnumber, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _body(hostname, serialnumber=None, nonce=NONCE):
|
||||
return {'hostname': hostname,
|
||||
'serialnumber': serialnumber or (hostname + '-SER'),
|
||||
'enrollnonce': nonce, 'agentversion': '1.20.0'}
|
||||
|
||||
|
||||
def _cap(db, value):
|
||||
Setting.set(PENDING_CAP_SETTING, value, valuetype='integer',
|
||||
category='plugin.endpoints')
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _pending(db, hostname, serialnumber=None, nonce=NONCE,
|
||||
status=EndpointEnrollRequest.STATUS_PENDING):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
row = EndpointEnrollRequest(
|
||||
hostname=hostname, serialnumber=serialnumber or (hostname + '-SER'),
|
||||
enrollnonce=nonce, status=status, requestedat=now)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _code(response):
|
||||
return response.get_json()['data']['error']['code']
|
||||
|
||||
|
||||
# -- rate limit --------------------------------------------------------------
|
||||
|
||||
def test_the_limit_triggers_and_answers_the_standard_shape(client, db, limits):
|
||||
"""Over budget is a 429 RATE_LIMITED, the same shape as every other
|
||||
limiter in the product."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_MAX'] = 3
|
||||
# An hour, so the fixed window cannot roll over mid-test and reset the
|
||||
# count. The test is about the budget, not about the clock.
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_WINDOW_SECONDS'] = 3600
|
||||
|
||||
codes = []
|
||||
for index in range(5):
|
||||
response = client.post(ENROLL_URL, json=_body('BAY-%02d' % index))
|
||||
codes.append(response.status_code)
|
||||
|
||||
assert codes[:3] == [202, 202, 202], codes
|
||||
assert codes[3:] == [429, 429], codes
|
||||
|
||||
refused = client.post(ENROLL_URL, json=_body('BAY-99'))
|
||||
assert refused.status_code == 429
|
||||
assert _code(refused) == 'RATE_LIMITED'
|
||||
|
||||
|
||||
def test_the_limit_is_off_when_the_config_says_so(client, db, limits):
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_MAX'] = 1
|
||||
|
||||
for index in range(4):
|
||||
response = client.post(ENROLL_URL, json=_body('OFF-%02d' % index))
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
|
||||
|
||||
# -- pending cap -------------------------------------------------------------
|
||||
|
||||
def test_the_cap_refuses_a_new_row(client, db, limits):
|
||||
"""At the cap, a hostname and serial nobody has seen is told to try later,
|
||||
and no row is written for it."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
_cap(db, 2)
|
||||
_pending(db, 'OLD-01')
|
||||
_pending(db, 'OLD-02')
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body('NEW-01'))
|
||||
|
||||
assert response.status_code == 503, response.get_data(as_text=True)
|
||||
assert _code(response) == 'ENROLL_PENDING_FULL'
|
||||
assert EndpointEnrollRequest.query.filter_by(hostname='NEW-01').count() == 0
|
||||
assert EndpointEnrollRequest.query.count() == 2
|
||||
|
||||
|
||||
def test_the_cap_still_serves_an_existing_pending_row(client, db, limits):
|
||||
"""A PC already in the queue keeps asking and keeps being answered - the cap
|
||||
must not lock the fleet out of the workflow it is protecting."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
_cap(db, 1)
|
||||
row = _pending(db, 'OLD-01')
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body('OLD-01'))
|
||||
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['status'] == 'pending'
|
||||
db.session.refresh(row)
|
||||
assert row.lastseenat is not None
|
||||
|
||||
|
||||
def test_the_cap_still_issues_to_an_approved_row(client, db, limits):
|
||||
"""An approved request hands out its credential at the cap, because that
|
||||
row is the admin's decision and refusing it would strand the PC."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
assetid = _computer(db, 'BAY-07', 'BAY-07-SER')
|
||||
_cap(db, 1)
|
||||
_pending(db, 'FILLER-01')
|
||||
approved = _pending(db, 'BAY-07',
|
||||
status=EndpointEnrollRequest.STATUS_APPROVED)
|
||||
approved.matchedassetid = assetid
|
||||
db.session.commit()
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body('BAY-07'))
|
||||
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['credential'].startswith('sdbe_')
|
||||
|
||||
|
||||
def test_the_cap_setting_is_honoured(client, db, limits):
|
||||
"""Raise the setting and the same request that was refused goes through;
|
||||
0 removes the cap entirely."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
_cap(db, 1)
|
||||
_pending(db, 'OLD-01')
|
||||
|
||||
refused = client.post(ENROLL_URL, json=_body('NEW-01'))
|
||||
assert refused.status_code == 503
|
||||
|
||||
_cap(db, 5)
|
||||
allowed = client.post(ENROLL_URL, json=_body('NEW-01'))
|
||||
assert allowed.status_code == 202, allowed.get_data(as_text=True)
|
||||
|
||||
_cap(db, 0)
|
||||
uncapped = client.post(ENROLL_URL, json=_body('NEW-02'))
|
||||
assert uncapped.status_code == 202, uncapped.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_the_cap_is_editable_through_the_enrollment_settings_route(
|
||||
client, db, auth_headers):
|
||||
"""A setting nobody can change is a constant with extra steps."""
|
||||
response = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'maxpendingenrollments': 42},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['maxpendingenrollments'] == 42
|
||||
assert int(Setting.get(PENDING_CAP_SETTING)) == 42
|
||||
|
||||
bad = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'maxpendingenrollments': -1}, headers=auth_headers)
|
||||
assert bad.status_code == 400
|
||||
352
tests/test_plugins/test_endpoints_enrollment_denials.py
Normal file
352
tests/test_plugins/test_endpoints_enrollment_denials.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""What a denial means at the enrollment door, and how long it lasts.
|
||||
|
||||
Three denials meet here, and each one used to be undone by the PC on the other
|
||||
side of it:
|
||||
|
||||
reject An admin refuses a request. The agent then sends a new nonce and
|
||||
the row was re-armed as a fresh cycle - which, with the shared key
|
||||
and auto-enroll on, auto-issued the credential the admin had just
|
||||
refused. A rejection binds to the identity (hostname + serial), so
|
||||
it survives any nonce; only an admin approving it clears it.
|
||||
cap The pending queue is bounded, and at the cap a PC nobody has seen
|
||||
before is refused. Abandoned rows come out first, so a queue that
|
||||
filled with junk drains itself instead of blocking every new PC.
|
||||
revoke A revoked credential falls back to the shared key, which is how
|
||||
the un-migrated fleet and the rollback to an older agent keep
|
||||
reporting. plugin.endpoints.revokeddeniessharedkey (off by
|
||||
default) turns a revoke into an eviction instead.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
|
||||
from plugins.endpoints.api import agentauth
|
||||
from plugins.endpoints.api.enrollment import (
|
||||
PENDING_CAP_SETTING, PENDING_STALE_AFTER,
|
||||
)
|
||||
from plugins.endpoints.models import EndpointCredential, EndpointEnrollRequest
|
||||
|
||||
|
||||
COLLECTOR_KEY = 'testcollectorkey'
|
||||
ENROLL_URL = '/api/endpoints/enroll'
|
||||
ALERTS_URL = '/api/endpoints/alerts'
|
||||
NONCE = 'n' * 40
|
||||
OTHER_NONCE = 'm' * 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keyheaders(app):
|
||||
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||
return {'X-API-Key': COLLECTOR_KEY}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def nolimiter(app):
|
||||
"""The per-IP limiter counts in a process-wide cache that outlives a test.
|
||||
|
||||
Nothing here is about how FAST a PC asks, and another test in the same
|
||||
worker may already have spent the budget for this window and address, so the
|
||||
limiter is off for this module and put back as it was.
|
||||
"""
|
||||
key = 'ENDPOINTS_ENROLL_RATELIMIT_ENABLED'
|
||||
previous = app.config.get(key)
|
||||
app.config[key] = False
|
||||
yield
|
||||
if previous is None:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = previous
|
||||
|
||||
|
||||
def _computer(db, hostname, serialnumber='SER-1'):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
serialnumber=serialnumber, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _body(hostname='BAY-01', serialnumber='SER-1', nonce=NONCE):
|
||||
return {'hostname': hostname, 'serialnumber': serialnumber,
|
||||
'enrollnonce': nonce, 'agentversion': '1.20.0'}
|
||||
|
||||
|
||||
def _enroll(client, headers, **kwargs):
|
||||
return client.post(ENROLL_URL, json=_body(**kwargs), headers=headers)
|
||||
|
||||
|
||||
def _pending_request(client, db, keyheaders, **kwargs):
|
||||
"""A request waiting for an admin, with auto-enroll back on afterwards.
|
||||
|
||||
Auto-enroll off is what puts a clean match in the queue instead of issuing
|
||||
at once; turning it back on is what made the old code hand the retry a
|
||||
credential, so every rejection test needs both halves.
|
||||
"""
|
||||
_setting(db, 'plugin.endpoints.autoenroll', False)
|
||||
response = _enroll(client, keyheaders, **kwargs)
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
_setting(db, 'plugin.endpoints.autoenroll', True)
|
||||
return response.get_json()['data']['requestid']
|
||||
|
||||
|
||||
def _reject(client, auth_headers, requestid):
|
||||
return client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/reject',
|
||||
headers=auth_headers)
|
||||
|
||||
|
||||
def _setting(db, key, value, valuetype='boolean'):
|
||||
Setting.set(key, value, valuetype=valuetype, category='plugin.endpoints')
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _code(response):
|
||||
return response.get_json()['data']['error']['code']
|
||||
|
||||
|
||||
# -- a rejection binds to the identity ---------------------------------------
|
||||
|
||||
def test_a_new_nonce_does_not_lift_a_rejection(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
"""The bug: reinstall the agent and the retry was auto-issued the
|
||||
credential an admin had just refused."""
|
||||
_computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
assert _reject(client, auth_headers, requestid).status_code == 200
|
||||
|
||||
retry = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
|
||||
assert retry.status_code == 202, retry.get_data(as_text=True)
|
||||
assert retry.get_json()['data']['reason'] == 'rejected'
|
||||
assert retry.get_json()['data']['requestid'] == requestid
|
||||
assert EndpointCredential.query.count() == 0
|
||||
row = db.session.get(EndpointEnrollRequest, requestid)
|
||||
assert row.status == EndpointEnrollRequest.STATUS_REJECTED
|
||||
assert row.decidedat is not None
|
||||
# Still one row for this identity: the retry did not queue a second.
|
||||
assert EndpointEnrollRequest.query.count() == 1
|
||||
|
||||
|
||||
def test_a_rejection_survives_however_often_the_pc_asks(client, db, app,
|
||||
keyheaders,
|
||||
auth_headers):
|
||||
_computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
|
||||
for index in range(5):
|
||||
again = _enroll(client, keyheaders, nonce=('x%d' % index) * 20)
|
||||
assert again.status_code == 202
|
||||
assert again.get_json()['data']['reason'] == 'rejected'
|
||||
|
||||
assert EndpointCredential.query.count() == 0
|
||||
assert EndpointEnrollRequest.query.count() == 1
|
||||
|
||||
|
||||
def test_a_rejection_does_not_touch_a_different_pc(client, db, app,
|
||||
keyheaders, auth_headers):
|
||||
"""Identity is hostname AND serial, so one PC's rejection is its own."""
|
||||
_computer(db, 'BAY-01', serialnumber='SER-1')
|
||||
other = _computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
|
||||
clean = _enroll(client, keyheaders, hostname='BAY-02',
|
||||
serialnumber='SER-2')
|
||||
|
||||
assert clean.status_code == 201, clean.get_data(as_text=True)
|
||||
assert EndpointCredential.query.filter_by(assetid=other).one() \
|
||||
.revokedat is None
|
||||
|
||||
|
||||
def test_an_admin_clears_a_rejection_by_approving_it(client, db, app,
|
||||
keyheaders, auth_headers):
|
||||
"""The way back is the admin's, and it works for the reinstalled agent that
|
||||
now carries a different nonce."""
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
_enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
|
||||
approve = client.post(
|
||||
f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
assert approve.status_code == 200, approve.get_data(as_text=True)
|
||||
|
||||
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
assert issued.status_code == 201, issued.get_data(as_text=True)
|
||||
assert EndpointCredential.query.filter_by(assetid=assetid).one() \
|
||||
.enrolledvia == 'approved'
|
||||
|
||||
|
||||
def test_an_approval_out_of_a_rejection_still_binds_to_one_installation(
|
||||
client, db, app, keyheaders, auth_headers):
|
||||
"""It binds to the installation the PC was last seen with, so a third
|
||||
machine claiming the same hostname and serial still cannot collect it."""
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
requestid = _pending_request(client, db, keyheaders)
|
||||
_reject(client, auth_headers, requestid)
|
||||
_enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
client.post(f'/api/endpoints/enrollment/requests/{requestid}/approve',
|
||||
json={'assetid': assetid}, headers=auth_headers)
|
||||
|
||||
impostor = _enroll(client, keyheaders, nonce='z' * 40)
|
||||
assert impostor.status_code == 403
|
||||
assert _code(impostor) == 'ENROLL_NONCE_MISMATCH'
|
||||
assert EndpointCredential.query.count() == 0
|
||||
|
||||
issued = _enroll(client, keyheaders, nonce=OTHER_NONCE)
|
||||
assert issued.status_code == 201, issued.get_data(as_text=True)
|
||||
|
||||
|
||||
# -- the pending cap ages out abandoned rows ---------------------------------
|
||||
|
||||
def _stale(db, hostname, days):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
seen = (datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(days=days))
|
||||
row = EndpointEnrollRequest(
|
||||
hostname=hostname, serialnumber=hostname + '-SER',
|
||||
enrollnonce=NONCE, status=EndpointEnrollRequest.STATUS_PENDING,
|
||||
requestedat=seen, lastseenat=seen)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def test_the_cap_ages_out_abandoned_rows_before_refusing(client, db, app):
|
||||
"""A queue full of PCs that stopped asking must not block the one that is."""
|
||||
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
|
||||
_stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
|
||||
_stale(db, 'GONE-02', PENDING_STALE_AFTER.days + 3)
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
|
||||
serialnumber='NEW-SER'))
|
||||
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
hostnames = {row.hostname for row in EndpointEnrollRequest.query.all()}
|
||||
assert hostnames == {'NEW-01'}
|
||||
|
||||
|
||||
def test_the_age_out_spares_rows_still_being_asked_for(client, db, app):
|
||||
"""Only rows nothing has touched go, and a decided row never goes: the
|
||||
rejection above has to outlive any queue pressure."""
|
||||
_setting(db, PENDING_CAP_SETTING, 2, valuetype='integer')
|
||||
fresh = _stale(db, 'LIVE-01', 0)
|
||||
stillasking = _stale(db, 'LIVE-02', 0)
|
||||
decided = _stale(db, 'GONE-01', PENDING_STALE_AFTER.days + 1)
|
||||
decided.status = EndpointEnrollRequest.STATUS_REJECTED
|
||||
db.session.commit()
|
||||
|
||||
refused = client.post(ENROLL_URL, json=_body(hostname='NEW-01',
|
||||
serialnumber='NEW-SER'))
|
||||
|
||||
assert refused.status_code == 503
|
||||
assert _code(refused) == 'ENROLL_PENDING_FULL'
|
||||
for row in (fresh, stillasking, decided):
|
||||
assert db.session.get(EndpointEnrollRequest, row.requestid) is not None
|
||||
|
||||
|
||||
# -- revocation and the shared key -------------------------------------------
|
||||
|
||||
def test_a_revoked_pc_keeps_the_shared_key_by_default(client, db, app,
|
||||
keyheaders,
|
||||
auth_headers):
|
||||
"""Today's behaviour, and the setting default: a revoke is a rotation, not
|
||||
an eviction, so the PC keeps reporting while it waits to enroll again."""
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
assert _enroll(client, keyheaders).status_code == 201
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
headers=auth_headers)
|
||||
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-01',
|
||||
headers=keyheaders).status_code == 200
|
||||
|
||||
|
||||
def test_revoked_denies_shared_key_evicts_the_pc(client, db, app, keyheaders,
|
||||
auth_headers):
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
assert _enroll(client, keyheaders).status_code == 201
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
json={'blockreenroll': True, 'reason': 'stolen'},
|
||||
headers=auth_headers)
|
||||
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||
|
||||
refused = client.get(ALERTS_URL + '?hostname=BAY-01', headers=keyheaders)
|
||||
|
||||
assert refused.status_code == 401
|
||||
assert _code(refused) == 'SHARED_KEY_REFUSED'
|
||||
assert 'revoked' in refused.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_pc_that_never_enrolled_is_never_evicted(client, db, app,
|
||||
keyheaders, auth_headers):
|
||||
"""The un-migrated fleet has no credential row at all, so the setting can be
|
||||
turned on without silencing it."""
|
||||
_computer(db, 'BAY-01', serialnumber='SER-1')
|
||||
_computer(db, 'BAY-02', serialnumber='SER-2')
|
||||
assetid = _computer(db, 'BAY-03', serialnumber='SER-3')
|
||||
_enroll(client, keyheaders, hostname='BAY-03', serialnumber='SER-3')
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
headers=auth_headers)
|
||||
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-01',
|
||||
headers=keyheaders).status_code == 200
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-02',
|
||||
headers=keyheaders).status_code == 200
|
||||
assert client.get(ALERTS_URL + '?hostname=BAY-03',
|
||||
headers=keyheaders).status_code == 401
|
||||
|
||||
|
||||
def test_the_collector_contract_follows_the_same_rule(client, db, app,
|
||||
keyheaders,
|
||||
auth_headers):
|
||||
"""The generic collector path asks the plugin, not bind_hostname, so it has
|
||||
to answer the same way."""
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
assetid = _computer(db, 'BAY-01')
|
||||
assert _enroll(client, keyheaders).status_code == 201
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
headers=auth_headers)
|
||||
refused = EndpointsPlugin().agent_shared_key_refused
|
||||
|
||||
with app.test_request_context():
|
||||
assert refused('BAY-01') is False
|
||||
_setting(db, agentauth.REVOKED_DENIES_SHARED_KEY_SETTING, True)
|
||||
assert refused('BAY-01') is True
|
||||
assert refused('UNKNOWN') is False
|
||||
|
||||
|
||||
def test_the_eviction_switch_is_editable_and_defaults_to_today(client, db,
|
||||
auth_headers):
|
||||
"""A setting nobody can change is a constant with extra steps."""
|
||||
read = client.get('/api/endpoints/enrollment',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert read['settings']['revokeddeniessharedkey'] is False
|
||||
|
||||
put = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'revokeddeniessharedkey': True},
|
||||
headers=auth_headers)
|
||||
assert put.status_code == 200, put.get_data(as_text=True)
|
||||
assert put.get_json()['data']['revokeddeniessharedkey'] is True
|
||||
assert Setting.get(agentauth.REVOKED_DENIES_SHARED_KEY_SETTING) is True
|
||||
|
||||
bad = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'revokeddeniessharedkey': 'yes'},
|
||||
headers=auth_headers)
|
||||
assert bad.status_code == 400
|
||||
485
tests/test_plugins/test_endpoints_enrollment_migration.py
Normal file
485
tests/test_plugins/test_endpoints_enrollment_migration.py
Normal file
@@ -0,0 +1,485 @@
|
||||
"""Moving the existing fleet onto per-PC credentials.
|
||||
|
||||
A PC on agent 1.19 or older holds only the shared key. It moves by receiving
|
||||
agent 1.20, which enrolls itself. The migration surface computes, per computer,
|
||||
whether that upgrade can be pushed, and pushes it in waves through the same
|
||||
enqueue path as the single-asset agentupgrade route, packaging once per wave.
|
||||
|
||||
The agent store is isolated per test, as in test_endpoints_agent_upgrade_route.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.endpoints.api import agentregistry, agentstore, agentupgradescript
|
||||
|
||||
MIGRATION_URL = '/api/endpoints/enrollment/migration'
|
||||
WAVE_URL = '/api/endpoints/enrollment/migration/wave'
|
||||
|
||||
_PE_BYTES = b'MZ' + b'\x00' * 4096
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_store(tmp_path, monkeypatch):
|
||||
siteagent = tmp_path / 'siteagent'
|
||||
packages = tmp_path / 'applicationpackages'
|
||||
scripts = tmp_path / 'scripts'
|
||||
for directory in (siteagent, packages, scripts):
|
||||
directory.mkdir()
|
||||
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(siteagent))
|
||||
monkeypatch.setattr(agentregistry, 'application_package_location',
|
||||
lambda: (str(packages), '/api/applications/package/'))
|
||||
monkeypatch.setattr(agentupgradescript, 'script_body_location',
|
||||
lambda: (str(scripts), '/api/scripts/body/'))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _publish_agent(version='1.20.0'):
|
||||
agentstore.store_version('agent', version, _PE_BYTES, make_current=True)
|
||||
|
||||
|
||||
def _pc(db, hostname, agentversion='1.19.0', serialnumber='SER',
|
||||
reportedago=timedelta(hours=1), reports=True):
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname + '-asset', name=hostname,
|
||||
assettypeid=atype.assettypeid, isactive=True,
|
||||
serialnumber=serialnumber)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
if reports:
|
||||
db.session.add(EndpointState(
|
||||
assetid=asset.assetid, hostname=hostname, agentversion=agentversion,
|
||||
lastreportedat=_now() - reportedago if reportedago else None))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _rows(client, headers, query=''):
|
||||
response = client.get(MIGRATION_URL + query, headers=headers)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
return response.get_json()['data']
|
||||
|
||||
|
||||
def _by_host(data):
|
||||
return {row['hostname']: row for row in data['computers']}
|
||||
|
||||
|
||||
def test_readiness_states(client, db, app, auth_headers):
|
||||
from plugins.endpoints.api.agentauth import generate_credential
|
||||
from plugins.endpoints.api.enrollment import issue_credential
|
||||
from plugins.endpoints.models import EndpointCommand, EndpointEnrollRequest
|
||||
|
||||
_publish_agent()
|
||||
enrolled = _pc(db, 'PC-ENROLLED')
|
||||
issue_credential(enrolled, 'auto')
|
||||
pending = _pc(db, 'PC-PENDING')
|
||||
db.session.add(EndpointEnrollRequest(
|
||||
hostname='pc-pending', serialnumber='SER', enrollnonce=generate_credential(),
|
||||
status='pending', reason='awaiting approval', matchedassetid=pending,
|
||||
requestedat=_now(), lastseenat=_now()))
|
||||
upgrading = _pc(db, 'PC-UPGRADING')
|
||||
_pc(db, 'PC-READY')
|
||||
_pc(db, 'PC-BLOCKED', serialnumber=None, reportedago=timedelta(days=3))
|
||||
_pc(db, 'PC-SHAREONLY', reports=False)
|
||||
_pc(db, 'PC-NEW', agentversion='1.20.0.0')
|
||||
db.session.commit()
|
||||
|
||||
seeded_app = agentregistry.register_current_agent_package()
|
||||
assert seeded_app is not None
|
||||
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
|
||||
db.session.add(EndpointCommand(assetid=upgrading, action='run_script',
|
||||
target=f'script:{scriptid}',
|
||||
status=EndpointCommand.STATUS_PENDING))
|
||||
db.session.commit()
|
||||
|
||||
data = _rows(client, auth_headers)
|
||||
rows = _by_host(data)
|
||||
assert data['target'] == '1.20.0'
|
||||
assert rows['PC-ENROLLED']['state'] == 'enrolled'
|
||||
assert rows['PC-PENDING']['state'] == 'pending'
|
||||
assert rows['PC-UPGRADING']['state'] == 'upgrading'
|
||||
assert rows['PC-READY']['state'] == 'ready'
|
||||
assert rows['PC-READY']['blockers'] == []
|
||||
assert rows['PC-BLOCKED']['state'] == 'blocked'
|
||||
assert rows['PC-BLOCKED']['blockers'] == ['offline', 'no serial on record']
|
||||
assert rows['PC-SHAREONLY']['state'] == 'notreporting'
|
||||
assert rows['PC-NEW']['state'] == 'blocked'
|
||||
assert rows['PC-NEW']['blockers'] == ['awaiting enrollment']
|
||||
assert data['counts'] == {'enrolled': 1, 'pending': 1, 'upgrading': 1,
|
||||
'ready': 1, 'blocked': 2, 'notreporting': 1}
|
||||
|
||||
only = _rows(client, auth_headers, '?state=ready')
|
||||
assert [row['hostname'] for row in only['computers']] == ['PC-READY']
|
||||
assert client.get(MIGRATION_URL + '?state=bogus',
|
||||
headers=auth_headers).status_code == 400
|
||||
|
||||
|
||||
def test_every_blocker_is_listed(client, db, app, auth_headers, monkeypatch):
|
||||
from plugins.endpoints import commands
|
||||
from plugins.endpoints.api import enrollment
|
||||
|
||||
# No package in the store at all.
|
||||
_pc(db, 'DUP')
|
||||
_pc(db, 'dup', agentversion='1.5.0')
|
||||
lockedid = _pc(db, 'LOCKED')
|
||||
commands.PLACE_ENDPOINT_LOCK(assetid=lockedid, reason='bench repair')
|
||||
db.session.commit()
|
||||
monkeypatch.setattr(enrollment, 'minimum_agent_version_for',
|
||||
lambda action: '1.10.0')
|
||||
|
||||
rows = {}
|
||||
for row in _rows(client, auth_headers)['computers']:
|
||||
rows.setdefault(row['hostname'], []).append(row)
|
||||
assert rows['dup'][0]['blockers'] == ['duplicate hostname',
|
||||
'no agent package in store',
|
||||
'agent too old for remote upgrade']
|
||||
assert rows['LOCKED'][0]['blockers'] == ['commands locked',
|
||||
'no agent package in store']
|
||||
|
||||
|
||||
def test_an_old_store_build_counts_as_no_package(client, db, app, auth_headers):
|
||||
_publish_agent('1.19.5')
|
||||
_pc(db, 'PC-A')
|
||||
row = _by_host(_rows(client, auth_headers))['PC-A']
|
||||
assert row['blockers'] == ['no agent package in store']
|
||||
|
||||
|
||||
def test_version_compare_is_numeric(client, db, app, auth_headers):
|
||||
_publish_agent('1.20.0')
|
||||
_pc(db, 'PC-NINE', agentversion='1.9.0')
|
||||
_pc(db, 'PC-HUNDRED', agentversion='1.100.0')
|
||||
rows = _by_host(_rows(client, auth_headers))
|
||||
assert rows['PC-NINE']['state'] == 'ready'
|
||||
assert rows['PC-HUNDRED']['blockers'] == ['awaiting enrollment']
|
||||
|
||||
|
||||
def test_wave_next_n_queues_ready_pcs_in_hostname_order(client, db, app,
|
||||
auth_headers):
|
||||
from plugins.endpoints.models import EndpointCommand, EndpointMigrationWave
|
||||
|
||||
_publish_agent()
|
||||
third = _pc(db, 'PC-C')
|
||||
first = _pc(db, 'PC-A')
|
||||
second = _pc(db, 'PC-B')
|
||||
_pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2))
|
||||
|
||||
response = client.post(WAVE_URL, json={'next': 2}, headers=auth_headers)
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data['queued'] == [first, second]
|
||||
assert data['skipped'] == []
|
||||
|
||||
commands = EndpointCommand.query.filter_by(action='run_script').all()
|
||||
assert sorted(c.assetid for c in commands) == [first, second]
|
||||
wave = EndpointMigrationWave.query.one()
|
||||
assert wave.waveid == data['waveid']
|
||||
assert wave.assetidlist == [first, second]
|
||||
|
||||
migration = _rows(client, auth_headers)
|
||||
rows = _by_host(migration)
|
||||
assert rows['PC-A']['state'] == 'upgrading'
|
||||
assert rows['PC-C']['state'] == 'ready'
|
||||
assert migration['waves'] == [{
|
||||
'waveid': wave.waveid, 'createdat': migration['waves'][0]['createdat'],
|
||||
'createdby': 'testadmin', 'size': 2, 'queued': 2, 'enrolled': 0,
|
||||
'failed': 0, 'done': False}]
|
||||
assert third not in data['queued']
|
||||
|
||||
|
||||
def test_wave_explicit_ids_rechecks_readiness(client, db, app, auth_headers):
|
||||
_publish_agent()
|
||||
ready = _pc(db, 'PC-READY')
|
||||
offline = _pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2))
|
||||
noagent = _pc(db, 'PC-NOAGENT', reports=False)
|
||||
|
||||
response = client.post(WAVE_URL,
|
||||
json={'assetids': [ready, offline, noagent, 999999]},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data['queued'] == [ready]
|
||||
assert data['skipped'] == [
|
||||
{'assetid': offline, 'reason': 'offline'},
|
||||
{'assetid': noagent, 'reason': 'notreporting'},
|
||||
{'assetid': 999999, 'reason': 'not a computer'},
|
||||
]
|
||||
|
||||
|
||||
def test_wave_packages_once(client, db, app, auth_headers, monkeypatch):
|
||||
from plugins.endpoints.api import routes
|
||||
|
||||
_publish_agent()
|
||||
ids = [_pc(db, f'PC-{n}') for n in range(3)]
|
||||
calls = []
|
||||
real = routes.prepare_agent_upgrade
|
||||
|
||||
def counting():
|
||||
calls.append(1)
|
||||
return real()
|
||||
|
||||
monkeypatch.setattr(routes, 'prepare_agent_upgrade', counting)
|
||||
response = client.post(WAVE_URL, json={'assetids': ids},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
assert response.get_json()['data']['queued'] == ids
|
||||
assert calls == [1]
|
||||
|
||||
|
||||
def test_wave_progress_counts_enrolled_and_failed(client, db, app,
|
||||
auth_headers):
|
||||
from plugins.endpoints.api.enrollment import issue_credential
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
good = _pc(db, 'PC-GOOD')
|
||||
bad = _pc(db, 'PC-BAD')
|
||||
client.post(WAVE_URL, json={'assetids': [good, bad]}, headers=auth_headers)
|
||||
|
||||
issue_credential(good, 'auto')
|
||||
EndpointCommand.query.filter_by(assetid=bad).one().status = \
|
||||
EndpointCommand.STATUS_FAILED
|
||||
db.session.commit()
|
||||
|
||||
wave = _rows(client, auth_headers)['waves'][0]
|
||||
assert (wave['size'], wave['enrolled'], wave['failed'], wave['done']) == \
|
||||
(2, 1, 1, True)
|
||||
|
||||
|
||||
def test_wave_without_ready_pcs_creates_nothing(client, db, app, auth_headers):
|
||||
from plugins.endpoints.models import EndpointMigrationWave
|
||||
|
||||
offline = _pc(db, 'PC-OFFLINE', reportedago=timedelta(days=2))
|
||||
response = client.post(WAVE_URL, json={'assetids': [offline]},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data']['waveid'] is None
|
||||
assert EndpointMigrationWave.query.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [{}, {'next': 0}, {'next': 101},
|
||||
{'assetids': []}, {'assetids': ['1']},
|
||||
{'assetids': list(range(1, 102))}])
|
||||
def test_wave_validation(client, db, app, auth_headers, body):
|
||||
assert client.post(WAVE_URL, json=body,
|
||||
headers=auth_headers).status_code == 400
|
||||
|
||||
|
||||
def test_a_reenroll_blocked_pc_is_blocked_not_ready(client, db, app,
|
||||
auth_headers):
|
||||
from plugins.endpoints.api.enrollment import issue_credential
|
||||
from plugins.endpoints.models import EndpointEnrollRequest
|
||||
|
||||
_publish_agent()
|
||||
assetid = _pc(db, 'PC-ROLLBACK')
|
||||
issue_credential(assetid, 'auto')
|
||||
db.session.commit()
|
||||
response = client.post(
|
||||
f'/api/endpoints/enrollment/assets/{assetid}/revoke',
|
||||
json={'blockreenroll': True, 'reason': 'rollback'},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
# Its agent keeps asking; that request must not turn the row into pending.
|
||||
db.session.add(EndpointEnrollRequest(
|
||||
hostname='PC-ROLLBACK', serialnumber='SER', enrollnonce='n' * 40,
|
||||
status='pending', reason='reenroll blocked', matchedassetid=assetid,
|
||||
requestedat=_now(), lastseenat=_now()))
|
||||
db.session.commit()
|
||||
|
||||
row = _by_host(_rows(client, auth_headers))['PC-ROLLBACK']
|
||||
assert row['state'] == 'blocked'
|
||||
assert row['blockers'] == ['reenroll blocked']
|
||||
|
||||
wave = client.post(WAVE_URL, json={'assetids': [assetid]},
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert wave['skipped'] == [{'assetid': assetid,
|
||||
'reason': 'reenroll blocked'}]
|
||||
|
||||
client.post(f'/api/endpoints/enrollment/assets/{assetid}/allow-reenroll',
|
||||
headers=auth_headers)
|
||||
assert _by_host(_rows(client, auth_headers))['PC-ROLLBACK']['state'] \
|
||||
== 'pending'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('message,detail', [
|
||||
('Script download failed: The SSL connection could not be established, '
|
||||
'see inner exception.', None),
|
||||
('Script download failed', {'error': 'The remote certificate is invalid '
|
||||
'because of errors in the certificate chain: '
|
||||
'UntrustedRoot'}),
|
||||
('Invoke-WebRequest: Could not establish trust relationship for the '
|
||||
'SSL/TLS secure channel.', None),
|
||||
])
|
||||
def test_a_tls_trust_failure_blocks_the_pc(client, db, app, auth_headers,
|
||||
message, detail):
|
||||
import json
|
||||
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _pc(db, 'PC-UNTRUSTED')
|
||||
otherid = _pc(db, 'PC-OTHERFAIL')
|
||||
agentregistry.register_current_agent_package()
|
||||
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
|
||||
db.session.add(EndpointCommand(
|
||||
assetid=assetid, action='run_script', target=f'script:{scriptid}',
|
||||
status=EndpointCommand.STATUS_FAILED, resultmessage=message,
|
||||
resultjson=json.dumps(detail) if detail is not None else None,
|
||||
completedat=_now()))
|
||||
db.session.add(EndpointCommand(
|
||||
assetid=otherid, action='run_script', target=f'script:{scriptid}',
|
||||
status=EndpointCommand.STATUS_FAILED,
|
||||
resultmessage='Script exited with code 1', completedat=_now()))
|
||||
db.session.commit()
|
||||
|
||||
rows = _by_host(_rows(client, auth_headers))
|
||||
assert rows['PC-UNTRUSTED']['state'] == 'blocked'
|
||||
assert rows['PC-UNTRUSTED']['blockers'] == [
|
||||
'server certificate not trusted by this PC']
|
||||
assert rows['PC-OTHERFAIL']['state'] == 'ready'
|
||||
|
||||
|
||||
def test_a_later_success_clears_the_tls_blocker(client, db, app, auth_headers):
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _pc(db, 'PC-FIXED')
|
||||
agentregistry.register_current_agent_package()
|
||||
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
|
||||
db.session.add(EndpointCommand(
|
||||
assetid=assetid, action='run_script', target=f'script:{scriptid}',
|
||||
status=EndpointCommand.STATUS_FAILED,
|
||||
resultmessage='RemoteCertificateChainErrors', completedat=_now()))
|
||||
db.session.flush()
|
||||
db.session.add(EndpointCommand(
|
||||
assetid=assetid, action='run_script', target='script:999',
|
||||
status=EndpointCommand.STATUS_SUCCEEDED, completedat=_now()))
|
||||
db.session.commit()
|
||||
|
||||
assert _by_host(_rows(client, auth_headers))['PC-FIXED']['state'] == 'ready'
|
||||
|
||||
|
||||
def test_a_trust_blocked_pc_is_retried_only_by_explicit_id(client, db, app,
|
||||
auth_headers):
|
||||
from plugins.endpoints.models import EndpointCommand, EndpointMigrationWave
|
||||
|
||||
_publish_agent()
|
||||
trustonly = _pc(db, 'PC-TRUST')
|
||||
alsooffline = _pc(db, 'PC-TRUST-OFFLINE', reportedago=timedelta(days=2))
|
||||
agentregistry.register_current_agent_package()
|
||||
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
|
||||
for assetid in (trustonly, alsooffline):
|
||||
db.session.add(EndpointCommand(
|
||||
assetid=assetid, action='run_script', target=f'script:{scriptid}',
|
||||
status=EndpointCommand.STATUS_FAILED,
|
||||
resultmessage='UntrustedRoot', completedat=_now()))
|
||||
db.session.commit()
|
||||
|
||||
rows = _by_host(_rows(client, auth_headers))
|
||||
assert rows['PC-TRUST']['blockers'] == [
|
||||
'server certificate not trusted by this PC']
|
||||
assert rows['PC-TRUST-OFFLINE']['blockers'] == [
|
||||
'offline', 'server certificate not trusted by this PC']
|
||||
|
||||
nextwave = client.post(WAVE_URL, json={'next': 10}, headers=auth_headers)
|
||||
assert nextwave.status_code == 200, nextwave.get_data(as_text=True)
|
||||
assert nextwave.get_json()['data']['queued'] == []
|
||||
assert nextwave.get_json()['data']['retried'] == []
|
||||
assert EndpointMigrationWave.query.count() == 0
|
||||
|
||||
response = client.post(WAVE_URL,
|
||||
json={'assetids': [trustonly, alsooffline]},
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
data = response.get_json()['data']
|
||||
assert data['queued'] == [trustonly]
|
||||
assert data['retried'] == [trustonly]
|
||||
assert data['skipped'] == [{
|
||||
'assetid': alsooffline,
|
||||
'reason': 'offline, server certificate not trusted by this PC'}]
|
||||
assert EndpointMigrationWave.query.one().assetidlist == [trustonly]
|
||||
assert _by_host(_rows(client, auth_headers))['PC-TRUST']['state'] \
|
||||
== 'upgrading'
|
||||
|
||||
|
||||
def test_a_trust_blocked_pc_still_needs_run_script_policy(client, db, app,
|
||||
auth_headers,
|
||||
monkeypatch):
|
||||
from plugins.endpoints.api import enrollment
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_publish_agent()
|
||||
assetid = _pc(db, 'PC-TRUST')
|
||||
agentregistry.register_current_agent_package()
|
||||
scriptid = agentupgradescript.seed_upgrade_script()['scriptid']
|
||||
db.session.add(EndpointCommand(
|
||||
assetid=assetid, action='run_script', target=f'script:{scriptid}',
|
||||
status=EndpointCommand.STATUS_FAILED,
|
||||
resultmessage='UntrustedRoot', completedat=_now()))
|
||||
db.session.commit()
|
||||
monkeypatch.setattr(enrollment, 'ALLOWED_ACTIONS_FOR',
|
||||
lambda assetid=None: set())
|
||||
|
||||
data = client.post(WAVE_URL, json={'assetids': [assetid]},
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert data['queued'] == []
|
||||
assert data['skipped'] == [{
|
||||
'assetid': assetid,
|
||||
'reason': 'server certificate not trusted by this PC, '
|
||||
'run_script not permitted by policy'}]
|
||||
|
||||
|
||||
def test_wave_failed_forgets_a_pc_a_later_wave_upgraded(client, db, app,
|
||||
auth_headers):
|
||||
from plugins.endpoints.models import EndpointCommand, EndpointState
|
||||
|
||||
_publish_agent()
|
||||
upgraded = _pc(db, 'PC-UPGRADED')
|
||||
retried = _pc(db, 'PC-RETRIED')
|
||||
stuck = _pc(db, 'PC-STUCK')
|
||||
first = client.post(WAVE_URL, json={'assetids': [upgraded, retried, stuck]},
|
||||
headers=auth_headers)
|
||||
assert first.status_code == 201, first.get_data(as_text=True)
|
||||
for command in EndpointCommand.query.all():
|
||||
command.status = EndpointCommand.STATUS_FAILED
|
||||
db.session.commit()
|
||||
assert _rows(client, auth_headers)['waves'][0]['failed'] == 3
|
||||
|
||||
second = client.post(WAVE_URL, json={'assetids': [upgraded, retried]},
|
||||
headers=auth_headers)
|
||||
assert second.status_code == 201, second.get_data(as_text=True)
|
||||
# The later wave replaced the agent on PC-UPGRADED, which restarted before
|
||||
# reporting, so its command expired; it has not enrolled yet. PC-RETRIED's
|
||||
# push reported success and is waiting to enroll on its old version string.
|
||||
latest = {command.assetid: command for command in EndpointCommand.query
|
||||
.filter_by(status=EndpointCommand.STATUS_PENDING).all()}
|
||||
latest[upgraded].status = EndpointCommand.STATUS_EXPIRED
|
||||
latest[retried].status = EndpointCommand.STATUS_SUCCEEDED
|
||||
EndpointState.query.filter_by(assetid=upgraded).one().agentversion = '1.20.0'
|
||||
db.session.commit()
|
||||
|
||||
failed = {wave['waveid']: wave['failed']
|
||||
for wave in _rows(client, auth_headers)['waves']}
|
||||
assert failed == {first.get_json()['data']['waveid']: 1,
|
||||
second.get_json()['data']['waveid']: 0}
|
||||
|
||||
# A newer failure counts again, in every wave the PC belongs to.
|
||||
latest[retried].status = EndpointCommand.STATUS_FAILED
|
||||
db.session.commit()
|
||||
failed = {wave['waveid']: wave['failed']
|
||||
for wave in _rows(client, auth_headers)['waves']}
|
||||
assert failed == {first.get_json()['data']['waveid']: 2,
|
||||
second.get_json()['data']['waveid']: 1}
|
||||
114
tests/test_plugins/test_endpoints_fleet_alert_scheduling.py
Normal file
114
tests/test_plugins/test_endpoints_fleet_alert_scheduling.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Fleet alert scheduling respects starttime and endtime.
|
||||
|
||||
Regression test for a bug where fleet alerts with a future starttime were sent
|
||||
immediately instead of waiting for the scheduled time. The agent polls on the
|
||||
collector credential, and the hostname must resolve to a Computer, so the
|
||||
fixtures mirror test_endpoints_fleet_alerts.py.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
|
||||
ALERTS_URL = '/api/endpoints/alerts'
|
||||
COLLECTOR_KEY = 'testcollectorkey'
|
||||
|
||||
|
||||
def _collector_headers(app):
|
||||
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||
return {'X-API-Key': COLLECTOR_KEY}
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
def _fleet_alert(db, message, starttime=None, endtime=None):
|
||||
from plugins.notifications.models import Notification, NotificationType
|
||||
|
||||
ntype = NotificationType.query.filter_by(typename='Fleet Alert').first()
|
||||
if not ntype:
|
||||
ntype = NotificationType(typename='Fleet Alert', typecolor='#ffc107',
|
||||
isactive=True)
|
||||
db.session.add(ntype)
|
||||
db.session.flush()
|
||||
note = Notification(notification=message,
|
||||
notificationtypeid=ntype.notificationtypeid,
|
||||
starttime=starttime, endtime=endtime, isactive=True,
|
||||
alertfleet=True, alertscope=None, alerttimeout=0)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
return note
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _poll(client, app, hostname):
|
||||
response = client.get(f'{ALERTS_URL}?hostname={hostname}',
|
||||
headers=_collector_headers(app))
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
body = response.get_json()
|
||||
assert body['status'] == 'success'
|
||||
return body['data']
|
||||
|
||||
|
||||
def test_fleet_alert_respects_future_starttime(client, db, app):
|
||||
"""A future starttime keeps the alert off the endpoint until it passes."""
|
||||
_computer(db, 'TEST-PC-01')
|
||||
now = _now()
|
||||
note = _fleet_alert(db, 'Scheduled maintenance at 3:30 PM',
|
||||
starttime=now + timedelta(minutes=20),
|
||||
endtime=now + timedelta(hours=2))
|
||||
|
||||
assert _poll(client, app, 'TEST-PC-01') == []
|
||||
|
||||
note.starttime = now - timedelta(minutes=5)
|
||||
db.session.commit()
|
||||
|
||||
alerts = _poll(client, app, 'TEST-PC-01')
|
||||
assert [a['notificationid'] for a in alerts] == [note.notificationid]
|
||||
assert alerts[0]['title'] == 'Scheduled maintenance at 3:30 PM'
|
||||
|
||||
|
||||
def test_fleet_alert_with_no_starttime_appears_immediately(client, db, app):
|
||||
_computer(db, 'TEST-PC-02')
|
||||
note = _fleet_alert(db, 'Immediate alert - urgent')
|
||||
|
||||
alerts = _poll(client, app, 'TEST-PC-02')
|
||||
assert [a['notificationid'] for a in alerts] == [note.notificationid]
|
||||
assert alerts[0]['title'] == 'Immediate alert - urgent'
|
||||
|
||||
|
||||
def test_fleet_alert_past_starttime_appears(client, db, app):
|
||||
_computer(db, 'TEST-PC-03')
|
||||
note = _fleet_alert(db, 'Alert started 1 hour ago',
|
||||
starttime=_now() - timedelta(hours=1))
|
||||
|
||||
alerts = _poll(client, app, 'TEST-PC-03')
|
||||
assert [a['notificationid'] for a in alerts] == [note.notificationid]
|
||||
|
||||
|
||||
def test_fleet_alert_expired_does_not_appear(client, db, app):
|
||||
_computer(db, 'TEST-PC-04')
|
||||
now = _now()
|
||||
_fleet_alert(db, 'This alert already ended',
|
||||
starttime=now - timedelta(hours=2),
|
||||
endtime=now - timedelta(hours=1))
|
||||
|
||||
assert _poll(client, app, 'TEST-PC-04') == []
|
||||
504
tests/test_plugins/test_endpoints_fleet_alerts.py
Normal file
504
tests/test_plugins/test_endpoints_fleet_alerts.py
Normal file
@@ -0,0 +1,504 @@
|
||||
"""Fleet alert delivery to the endpoint agent, and terminal acknowledgement.
|
||||
|
||||
A fleet alert is a notification flagged alertfleet. The endpoint agent polls
|
||||
GET /api/endpoints/alerts?hostname=... on the endpoints collector key - the same
|
||||
credential and kill switch as the rest of the agent channel, because a shop
|
||||
floor bay has no user session - and the tray surfaces what comes back. When the
|
||||
operator dismisses one, the agent posts it to the acknowledge route.
|
||||
|
||||
Two contracts matter here and both are load-bearing for the durable agent-side
|
||||
surface:
|
||||
|
||||
1. The poll response is AUTHORITATIVE for that host. It is every alert that
|
||||
should be live on the machine right now, so the agent can retire a pending
|
||||
alert that has disappeared from it. That is the only way a server-side
|
||||
close or cancel reaches an endpoint already displaying the alert.
|
||||
|
||||
2. Acknowledgement is TERMINAL for an asset. Once acked, the alert never comes
|
||||
back for that machine, and a later server-side close is a no-op. This is
|
||||
what lets the agent retry a queued acknowledgement safely.
|
||||
|
||||
The cross-machine guard gets its own test because it regressed into an
|
||||
AttributeError: the handler read .assetid off _resolve_asset_for_hostname,
|
||||
which returns the id itself, so the check raised on every call and one bay could
|
||||
not be proven unable to acknowledge another bay's alert.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
|
||||
ALERTS_URL = '/api/endpoints/alerts'
|
||||
ACK_URL = '/api/endpoints/alerts/{}/acknowledge'
|
||||
COLLECTOR_KEY = 'testcollectorkey'
|
||||
|
||||
|
||||
def _collector_headers(app):
|
||||
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||
return {'X-API-Key': COLLECTOR_KEY}
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
"""Create a computer Asset + Computer; return the asset."""
|
||||
from plugins.computers.models import Computer, ComputerType
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
|
||||
ctype = ComputerType.query.filter_by(computertype='CMM PC').first()
|
||||
if not ctype:
|
||||
ctype = ComputerType(computertype='CMM PC')
|
||||
db.session.add(ctype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
comp = Computer(assetid=asset.assetid, hostname=hostname,
|
||||
computertypeid=ctype.computertypeid)
|
||||
db.session.add(comp)
|
||||
db.session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
def _alert_type(db):
|
||||
from plugins.notifications.models import NotificationType
|
||||
|
||||
ntype = NotificationType.query.filter_by(typename='Fleet Alert').first()
|
||||
if not ntype:
|
||||
ntype = NotificationType(typename='Fleet Alert', typecolor='#d93f3f',
|
||||
isactive=True)
|
||||
db.session.add(ntype)
|
||||
db.session.commit()
|
||||
return ntype
|
||||
|
||||
|
||||
def _fleet_alert(db, message='Line 3 is down', isactive=True, endtime=None,
|
||||
timeout=60):
|
||||
from plugins.notifications.models import Notification
|
||||
|
||||
ntype = _alert_type(db)
|
||||
note = Notification(notificationtypeid=ntype.notificationtypeid,
|
||||
notification=message, alertfleet=True,
|
||||
alerttimeout=timeout, isactive=isactive,
|
||||
endtime=endtime)
|
||||
db.session.add(note)
|
||||
db.session.commit()
|
||||
return note
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
from datetime import datetime, timezone
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def test_agent_polls_its_active_fleet_alerts(client, db, app):
|
||||
"""An active fleet alert reaches the endpoint that asks for it."""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db, message='Line 3 is down')
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert resp.status_code == 200, resp.get_data(as_text=True)
|
||||
alerts = resp.get_json()['data']
|
||||
assert [a['notificationid'] for a in alerts] == [alert.notificationid]
|
||||
assert alerts[0]['message'] == 'Line 3 is down'
|
||||
assert alerts[0]['timeout'] == 60
|
||||
# The tray renders the type colour, so it has to come down with the alert
|
||||
# rather than being guessed client side.
|
||||
assert alerts[0]['typecolor'] == '#d93f3f'
|
||||
|
||||
|
||||
def test_the_feed_still_carries_the_legacy_camelcase_keys(client, db, app):
|
||||
"""TRANSITION for trays older than 1.7.0.
|
||||
|
||||
A deployed 1.6.x tray fetches this feed itself and deserializes
|
||||
case-sensitively, so serving only the lowercase keys would make every alert
|
||||
disappear on those PCs with no error anywhere. Both spellings go down, with
|
||||
identical values, until the fleet is upgraded - then this test and the two
|
||||
camelCase keys go together.
|
||||
"""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db, message='Line 3 is down')
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
|
||||
headers=_collector_headers(app))
|
||||
|
||||
row = resp.get_json()['data'][0]
|
||||
assert row['notificationId'] == row['notificationid'] == alert.notificationid
|
||||
assert row['typeColor'] == row['typecolor'] == '#d93f3f'
|
||||
|
||||
|
||||
def test_poll_requires_the_collector_key(client, db, app):
|
||||
"""The alert feed is agent-facing, not anonymous."""
|
||||
_computer(db, 'BAY-01')
|
||||
_fleet_alert(db)
|
||||
# Configure the key here rather than inheriting it from whichever test ran
|
||||
# first: under xdist this file is split across workers, and an unconfigured
|
||||
# key answers 500 instead of the 401 the test is about.
|
||||
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01')
|
||||
|
||||
assert resp.status_code in (401, 403), resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_unknown_hostname_is_not_found(client, db, app):
|
||||
"""A hostname that resolves to no asset is a 404, not an empty list."""
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=NOSUCHBAY',
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_deactivated_alert_drops_out_of_the_feed(client, db, app):
|
||||
"""Server-side close: the alert stops being offered to the endpoint.
|
||||
|
||||
This is how a cancel reaches a machine. The feed is authoritative, so an
|
||||
alert missing from it is the agent's instruction to retire the pending
|
||||
toast it is holding.
|
||||
"""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
|
||||
alert.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == []
|
||||
|
||||
|
||||
def test_expired_alert_drops_out_of_the_feed(client, db, app):
|
||||
"""An alert past its endtime is withdrawn the same way a closed one is."""
|
||||
_computer(db, 'BAY-01')
|
||||
_fleet_alert(db, endtime=_naive_utcnow() - timedelta(minutes=5))
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == []
|
||||
|
||||
|
||||
def test_acknowledgement_removes_the_alert_for_that_asset(client, db, app):
|
||||
"""Acknowledgement is terminal: the alert never returns for this machine."""
|
||||
asset = _computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
headers = _collector_headers(app)
|
||||
|
||||
ack = client.post(ACK_URL.format(alert.notificationid),
|
||||
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
|
||||
headers=headers)
|
||||
assert ack.status_code == 201, ack.get_data(as_text=True)
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01', headers=headers)
|
||||
assert resp.get_json()['data'] == []
|
||||
|
||||
|
||||
def test_acknowledgement_is_idempotent(client, db, app):
|
||||
"""The agent retries a queued acknowledgement, so a repeat must succeed.
|
||||
|
||||
Acknowledgement is terminal and the agent holds it on disk until the server
|
||||
confirms, which means the same ack can legitimately arrive twice after a
|
||||
network outage. The second one is a success, not a duplicate-key error.
|
||||
"""
|
||||
from plugins.notifications.models import NotificationAcknowledgement
|
||||
|
||||
asset = _computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
headers = _collector_headers(app)
|
||||
payload = {'hostname': 'BAY-01', 'assetid': asset.assetid}
|
||||
|
||||
first = client.post(ACK_URL.format(alert.notificationid), json=payload,
|
||||
headers=headers)
|
||||
second = client.post(ACK_URL.format(alert.notificationid), json=payload,
|
||||
headers=headers)
|
||||
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 200, second.get_data(as_text=True)
|
||||
assert second.get_json()['data']['acknowledged'] is True
|
||||
|
||||
rows = (NotificationAcknowledgement.query
|
||||
.filter_by(notificationid=alert.notificationid,
|
||||
assetid=asset.assetid).all())
|
||||
assert len(rows) == 1, 'a retried acknowledgement wrote a second row'
|
||||
|
||||
|
||||
def test_closing_an_acknowledged_alert_changes_nothing(client, db, app):
|
||||
"""A server-side close after acknowledgement is a no-op.
|
||||
|
||||
Acknowledgement already retired the alert for this asset, so there is
|
||||
nothing left to withdraw and the alert must not reappear.
|
||||
"""
|
||||
asset = _computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
headers = _collector_headers(app)
|
||||
|
||||
client.post(ACK_URL.format(alert.notificationid),
|
||||
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
|
||||
headers=headers)
|
||||
|
||||
alert.isactive = False
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01', headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == []
|
||||
|
||||
|
||||
def test_one_bay_cannot_acknowledge_for_another(client, db, app):
|
||||
"""The hostname must match the assetid it claims.
|
||||
|
||||
This guard regressed into an AttributeError and never actually ran, so it
|
||||
is pinned: the collector key is shared across the fleet, and without this
|
||||
check any endpoint could clear an alert on another machine's behalf.
|
||||
"""
|
||||
_computer(db, 'BAY-01')
|
||||
other = _computer(db, 'BAY-02')
|
||||
alert = _fleet_alert(db)
|
||||
|
||||
resp = client.post(ACK_URL.format(alert.notificationid),
|
||||
json={'hostname': 'BAY-01', 'assetid': other.assetid},
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert resp.status_code == 400, resp.get_data(as_text=True)
|
||||
|
||||
# And BAY-02's alert is untouched by the attempt.
|
||||
feed = client.get(f'{ALERTS_URL}?hostname=BAY-02',
|
||||
headers=_collector_headers(app))
|
||||
assert [a['notificationid'] for a in feed.get_json()['data']] == [
|
||||
alert.notificationid]
|
||||
|
||||
|
||||
def test_acknowledging_one_bay_leaves_the_others_alerted(client, db, app):
|
||||
"""A fleet alert is per-asset: one operator does not clear it for everyone."""
|
||||
asset = _computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02')
|
||||
alert = _fleet_alert(db)
|
||||
headers = _collector_headers(app)
|
||||
|
||||
client.post(ACK_URL.format(alert.notificationid),
|
||||
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
|
||||
headers=headers)
|
||||
|
||||
still = client.get(f'{ALERTS_URL}?hostname=BAY-02', headers=headers)
|
||||
assert [a['notificationid'] for a in still.get_json()['data']] == [
|
||||
alert.notificationid]
|
||||
|
||||
|
||||
def test_acknowledge_requires_the_collector_key(client, db, app):
|
||||
"""Acknowledgement is agent-facing and must not answer an anonymous caller."""
|
||||
asset = _computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
|
||||
resp = client.post(ACK_URL.format(alert.notificationid),
|
||||
json={'hostname': 'BAY-01', 'assetid': asset.assetid})
|
||||
|
||||
assert resp.status_code in (401, 403), resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_acknowledging_an_unknown_notification_is_not_found(client, db, app):
|
||||
"""A stale alert id from a rebuilt server is a 404, not a 500."""
|
||||
asset = _computer(db, 'BAY-01')
|
||||
|
||||
resp = client.post(ACK_URL.format(999999),
|
||||
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_non_fleet_notification_is_never_delivered(client, db, app):
|
||||
"""Ordinary board notifications are not agent alerts and must not leak."""
|
||||
from plugins.notifications.models import Notification
|
||||
|
||||
_computer(db, 'BAY-01')
|
||||
ntype = _alert_type(db)
|
||||
db.session.add(Notification(notificationtypeid=ntype.notificationtypeid,
|
||||
notification='Board only', alertfleet=False,
|
||||
isactive=True))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data'] == []
|
||||
|
||||
|
||||
# --- targeting -------------------------------------------------------------
|
||||
#
|
||||
# alertscope narrows a fleet alert to part of the floor. It was previously
|
||||
# stored, serialised and editable in the authoring form but never read here, so
|
||||
# every alert went to every endpoint no matter what was entered - a control that
|
||||
# looked like it worked and did not. These pin the behaviour it should have had.
|
||||
|
||||
|
||||
def _ids(client, app, hostname):
|
||||
resp = client.get(f'{ALERTS_URL}?hostname={hostname}',
|
||||
headers=_collector_headers(app))
|
||||
assert resp.status_code == 200, resp.get_data(as_text=True)
|
||||
return [a['notificationid'] for a in resp.get_json()['data']]
|
||||
|
||||
|
||||
def test_no_scope_still_means_the_whole_fleet(client, db, app):
|
||||
"""The default is unchanged: an unscoped alert reaches everyone."""
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02')
|
||||
alert = _fleet_alert(db)
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
assert _ids(client, app, 'BAY-02') == [alert.notificationid]
|
||||
|
||||
|
||||
def test_an_empty_scope_means_the_whole_fleet(client, db, app):
|
||||
"""An empty dict is not a match-nothing."""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = {}
|
||||
db.session.commit()
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
|
||||
|
||||
def test_hostname_scope_targets_one_endpoint(client, db, app):
|
||||
"""The narrowest case, and the one the test card drives."""
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = {'hostname': 'BAY-01'}
|
||||
db.session.commit()
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
assert _ids(client, app, 'BAY-02') == []
|
||||
|
||||
|
||||
def test_hostname_scope_is_case_insensitive(client, db, app):
|
||||
"""Operators type these by hand; case must not decide delivery."""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = {'hostname': 'bay-01'}
|
||||
db.session.commit()
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
|
||||
|
||||
def test_a_scope_may_list_several_values(client, db, app):
|
||||
"""Any value in a list matches, so a scope can name a handful of bays."""
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02')
|
||||
_computer(db, 'BAY-03')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = {'hostname': ['BAY-01', 'BAY-03']}
|
||||
db.session.commit()
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
assert _ids(client, app, 'BAY-02') == []
|
||||
assert _ids(client, app, 'BAY-03') == [alert.notificationid]
|
||||
|
||||
|
||||
def test_computertype_scope_targets_a_class_of_machine(client, db, app):
|
||||
"""Targeting by what the machine is, not which one it is."""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = {'computertype': 'CMM PC'}
|
||||
db.session.commit()
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
|
||||
alert.alertscope = {'computertype': 'Kiosk'}
|
||||
db.session.commit()
|
||||
assert _ids(client, app, 'BAY-01') == []
|
||||
|
||||
|
||||
def test_several_scope_keys_must_all_match(client, db, app):
|
||||
"""Keys narrow together - it is an AND, not an OR."""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
|
||||
alert.alertscope = {'hostname': 'BAY-01', 'computertype': 'CMM PC'}
|
||||
db.session.commit()
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
|
||||
# Right host, wrong type: the alert must not land.
|
||||
alert.alertscope = {'hostname': 'BAY-01', 'computertype': 'Kiosk'}
|
||||
db.session.commit()
|
||||
assert _ids(client, app, 'BAY-01') == []
|
||||
|
||||
|
||||
def test_an_unknown_scope_key_is_ignored_not_fatal(client, db, app):
|
||||
"""A typo must not silently mute an alert everywhere.
|
||||
|
||||
Scopes are authored by hand. If {"hostnam": "BAY-01"} matched nothing, the
|
||||
alert would vanish fleet-wide and look like a delivery failure. Sending it
|
||||
wider than intended is the safe direction for something whose job is to warn
|
||||
people, so unrecognised keys are ignored.
|
||||
"""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = {'hostnam': 'BAY-01'}
|
||||
db.session.commit()
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
|
||||
|
||||
def test_a_non_dict_scope_fails_closed(client, db, app):
|
||||
"""A scope that is present but not an object is corrupt, not fleet-wide.
|
||||
|
||||
Create/update refuse such a scope, so a row carrying one did not come from
|
||||
an operator's intent to reach everyone. Broadcasting it could put a
|
||||
one-bay alert on every screen, so it reaches nobody.
|
||||
"""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = 'BAY-01'
|
||||
db.session.commit()
|
||||
assert _ids(client, app, 'BAY-01') == []
|
||||
|
||||
alert.alertscope = ['BAY-01']
|
||||
db.session.commit()
|
||||
assert _ids(client, app, 'BAY-01') == []
|
||||
|
||||
|
||||
def test_a_json_string_scope_is_parsed(client, db, app):
|
||||
"""Legacy rows written through the TEXT column hold the object as text."""
|
||||
_computer(db, 'BAY-01')
|
||||
_computer(db, 'BAY-02')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = '{"hostname": "BAY-01"}'
|
||||
db.session.commit()
|
||||
|
||||
assert _ids(client, app, 'BAY-01') == [alert.notificationid]
|
||||
assert _ids(client, app, 'BAY-02') == []
|
||||
|
||||
|
||||
def test_scope_keys_match_the_notifications_validator():
|
||||
"""Create/update validation and the matcher must agree on the key set."""
|
||||
from plugins.endpoints.api.routes import _ALERT_SCOPE_KEYS
|
||||
from plugins.notifications.models.notification import ALERT_SCOPE_KEYS
|
||||
|
||||
assert set(_ALERT_SCOPE_KEYS) == set(ALERT_SCOPE_KEYS)
|
||||
|
||||
|
||||
def test_scoping_does_not_leak_into_acknowledgement(client, db, app):
|
||||
"""A targeted alert is acknowledged the same way any other one is."""
|
||||
asset = _computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db)
|
||||
alert.alertscope = {'hostname': 'BAY-01'}
|
||||
db.session.commit()
|
||||
|
||||
ack = client.post(ACK_URL.format(alert.notificationid),
|
||||
json={'hostname': 'BAY-01', 'assetid': asset.assetid},
|
||||
headers=_collector_headers(app))
|
||||
|
||||
assert ack.status_code == 201
|
||||
assert _ids(client, app, 'BAY-01') == []
|
||||
110
tests/test_plugins/test_endpoints_fleet_alias.py
Normal file
110
tests/test_plugins/test_endpoints_fleet_alias.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""The Fleet list must carry the asset alias.
|
||||
|
||||
The alias is what people actually call a PC ("Bay 3 Controller"); the hostname
|
||||
is what DNS calls it. The PCs list has shown both for a long time, but the
|
||||
Fleet list query never selected Asset.name, so the column could not be built
|
||||
even though the data existed - it was missing rather than broken.
|
||||
|
||||
These pin the field on the payload and its inclusion in search, so the column
|
||||
cannot quietly lose its source again.
|
||||
"""
|
||||
|
||||
LIST_URL = '/api/endpoints/'
|
||||
|
||||
|
||||
def _endpoint(db, hostname='fleetbox', alias='Bay 3 Controller'):
|
||||
"""An Asset + Computer + EndpointState triple: one row on the fleet list.
|
||||
|
||||
The list joins through Computer and filters on Asset.isactive, so all three
|
||||
are required for a row to appear at all.
|
||||
"""
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=alias,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _rows(response):
|
||||
return response.get_json()['data']
|
||||
|
||||
|
||||
def test_the_fleet_list_carries_the_alias(client, db, auth_headers):
|
||||
_endpoint(db)
|
||||
|
||||
response = client.get(LIST_URL, headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = _rows(response)
|
||||
assert len(rows) == 1
|
||||
# Exposed as 'alias', not 'name': the row already has a hostname, and
|
||||
# 'name' beside 'hostname' reads as a duplicate of the same thing.
|
||||
assert rows[0]['alias'] == 'Bay 3 Controller'
|
||||
|
||||
|
||||
def test_an_endpoint_without_an_alias_still_lists(client, db, auth_headers):
|
||||
# The column renders '-' for these. A null must not drop the row or 500.
|
||||
_endpoint(db, hostname='namelessbox', alias=None)
|
||||
|
||||
response = client.get(LIST_URL, headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = _rows(response)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['alias'] is None
|
||||
assert rows[0]['hostname'] == 'namelessbox'
|
||||
|
||||
|
||||
def test_the_alias_is_searchable(client, db, auth_headers):
|
||||
# Someone looking for a PC types what it is called, not its hostname.
|
||||
_endpoint(db, hostname='pc-a', alias='Bay 3 Controller')
|
||||
_endpoint(db, hostname='pc-b', alias='Paint Booth')
|
||||
|
||||
response = client.get(LIST_URL + '?search=Bay 3', headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = _rows(response)
|
||||
assert [r['hostname'] for r in rows] == ['pc-a']
|
||||
|
||||
|
||||
def test_searching_by_hostname_still_works(client, db, auth_headers):
|
||||
# The alias clause was added to an existing OR; prove it did not replace
|
||||
# what was already there.
|
||||
_endpoint(db, hostname='pc-a', alias='Bay 3 Controller')
|
||||
_endpoint(db, hostname='pc-b', alias='Paint Booth')
|
||||
|
||||
response = client.get(LIST_URL + '?search=pc-b', headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = _rows(response)
|
||||
assert [r['hostname'] for r in rows] == ['pc-b']
|
||||
|
||||
|
||||
def test_the_row_keeps_its_other_columns(client, db, auth_headers):
|
||||
# The alias was added mid-tuple in the query, so every following column
|
||||
# shifted position. If that unpacking is wrong the values silently swap
|
||||
# rather than raising.
|
||||
_endpoint(db, hostname='fleetbox', alias='Bay 3 Controller')
|
||||
|
||||
response = client.get(LIST_URL, headers=auth_headers)
|
||||
|
||||
row = _rows(response)[0]
|
||||
assert row['hostname'] == 'fleetbox'
|
||||
assert row['assetnumber'] == 'fleetbox'
|
||||
assert row['alias'] == 'Bay 3 Controller'
|
||||
assert row['status'] in ('never', 'quiet', 'reporting')
|
||||
assert 'computerid' in row and row['computerid'] is not None
|
||||
320
tests/test_plugins/test_endpoints_health.py
Normal file
320
tests/test_plugins/test_endpoints_health.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""Health rollup: conditions outrank event counts, and no policy still works.
|
||||
|
||||
The bug this locks down: the Fleet Health column ranked a raw Windows event
|
||||
count above every other signal, so a box with a crash loop reported
|
||||
"Application error x5861" while its dead eDNC service was structurally
|
||||
unreachable - the event branch returned before services were ever considered.
|
||||
|
||||
The other half is the no-policy path. An endpoint with no policies, no PC type,
|
||||
and no monitor impact rows is the NORMAL case here, not an edge case, and it
|
||||
must still produce a verdict. A policy binding may only RAISE impact; it may
|
||||
never gate whether health is computed at all.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from plugins.endpoints.models import (
|
||||
EndpointState, EndpointEvent, EndpointMonitor,
|
||||
EndpointHealthState, EndpointHealthTransition, EndpointMonitorImpact,
|
||||
)
|
||||
from plugins.endpoints.models.health import (
|
||||
IMPACT_BLOCKER, IMPACT_DEGRADED, SOURCE_DRIVE, SOURCE_EVENT,
|
||||
SOURCE_SERVICE,
|
||||
)
|
||||
from plugins.endpoints.services.health import (
|
||||
HEALTH_WINDOW_HOURS, TRANSITION_HIGHWATER, TRANSITION_RETENTION,
|
||||
evaluate_endpoint_health,
|
||||
)
|
||||
|
||||
NOW = datetime(2025, 1, 15, 12, 0, 0)
|
||||
|
||||
# A curated CRITICAL id, so the event path is genuinely exercised rather than
|
||||
# filtered out by the catalog. 1000 is 'Application error'; note 1001 was
|
||||
# deliberately demoted to informational and would not surface here.
|
||||
CRASH_EVENTID = 1000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint(db):
|
||||
"""One computer asset with a state row. No policies, no PC type."""
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not assettype:
|
||||
assettype = AssetType(assettype='computer')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber='HEALTH-PC-1', assettypeid=assettype.assettypeid)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
|
||||
state = EndpointState(assetid=asset.assetid, lastreportedat=NOW)
|
||||
db.session.add(state)
|
||||
db.session.commit()
|
||||
return state
|
||||
|
||||
|
||||
def _services(*entries):
|
||||
return json.dumps(list(entries))
|
||||
|
||||
|
||||
def _drives(*statuses):
|
||||
"""A usercontext payload with one netuse drive per (letter, status)."""
|
||||
return json.dumps({
|
||||
'netuse': [{'localname': letter, 'remotename': r'\\server\share',
|
||||
'status': status}
|
||||
for letter, status in statuses]
|
||||
})
|
||||
|
||||
|
||||
def _events(db, assetid, count, hoursago=1):
|
||||
for index in range(count):
|
||||
db.session.add(EndpointEvent(
|
||||
assetid=assetid,
|
||||
eventid=CRASH_EVENTID,
|
||||
eventrecordid=index + 1,
|
||||
eventchannel='Application',
|
||||
eventtime=NOW - timedelta(hours=hoursago),
|
||||
))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _monitor(db, servicename):
|
||||
monitor = EndpointMonitor(kind='service', displayname=servicename,
|
||||
servicename=servicename)
|
||||
db.session.add(monitor)
|
||||
db.session.flush()
|
||||
return monitor
|
||||
|
||||
|
||||
def test_no_policy_endpoint_still_gets_a_verdict(db, endpoint):
|
||||
"""Zero policies, zero monitors, zero impact rows - still resolves.
|
||||
|
||||
The whole no-policy path in one assertion: absence of configuration is the
|
||||
default, not a failure, and it must not read as unknown either.
|
||||
"""
|
||||
assert EndpointMonitorImpact.query.count() == 0
|
||||
assert EndpointMonitor.query.count() == 0
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.level == 'ok'
|
||||
assert result.impactclass is None
|
||||
|
||||
|
||||
def test_stopped_service_falls_back_to_degraded_without_an_impact_row(
|
||||
db, endpoint):
|
||||
"""No EndpointMonitorImpact row means the built-in default applies.
|
||||
|
||||
This is the verdict the old rollup gave a stopped watched service, kept
|
||||
deliberately so shipping the mapping table changes nothing until someone
|
||||
actually maps something.
|
||||
"""
|
||||
endpoint.servicesjson = _services(
|
||||
{'name': 'eDNC', 'state': 'Stopped', 'monitorid': 4242})
|
||||
db.session.commit()
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.impactclass == IMPACT_DEGRADED
|
||||
assert result.level == 'warning'
|
||||
assert result.label == 'eDNC stopped'
|
||||
|
||||
|
||||
def test_impact_is_a_property_of_the_monitor_in_context(db, endpoint):
|
||||
"""Policy may RAISE impact, and only for the PC type it was mapped to.
|
||||
|
||||
Same service, same telemetry, two PC types: a blocker on the shopfloor
|
||||
machine that runs parts, merely degraded on one that does not.
|
||||
"""
|
||||
monitor = _monitor(db, 'eDNC')
|
||||
db.session.add(EndpointMonitorImpact(
|
||||
endpointmonitorid=monitor.endpointmonitorid,
|
||||
computertypeid=7,
|
||||
impactclass=IMPACT_BLOCKER,
|
||||
createdat=NOW))
|
||||
endpoint.servicesjson = _services(
|
||||
{'name': 'eDNC', 'state': 'Stopped',
|
||||
'monitorid': monitor.endpointmonitorid})
|
||||
db.session.commit()
|
||||
|
||||
raised = evaluate_endpoint_health(endpoint.assetid, endpoint,
|
||||
computertypeid=7, now=NOW)
|
||||
db.session.commit()
|
||||
assert raised.impactclass == IMPACT_BLOCKER
|
||||
assert raised.level == 'critical'
|
||||
|
||||
# PC type 9 has no mapping for this monitor, so the default applies.
|
||||
default = evaluate_endpoint_health(endpoint.assetid, endpoint,
|
||||
computertypeid=9, now=NOW)
|
||||
db.session.commit()
|
||||
assert default.impactclass == IMPACT_DEGRADED
|
||||
|
||||
|
||||
def test_a_dead_drive_outranks_a_large_crash_count(db, endpoint):
|
||||
"""THE REGRESSION. 200 crashes must not hide a disconnected drive."""
|
||||
endpoint.usercontextjson = _drives(('S:', 'Disconnected'))
|
||||
db.session.commit()
|
||||
_events(db, endpoint.assetid, 200)
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.sourcekind == SOURCE_DRIVE
|
||||
assert result.impactclass == IMPACT_BLOCKER
|
||||
assert 'S:' in result.label
|
||||
|
||||
|
||||
def test_a_stopped_service_outranks_an_event_at_equal_impact(db, endpoint):
|
||||
"""A live condition beats a report of something that already finished."""
|
||||
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Stopped'})
|
||||
db.session.commit()
|
||||
_events(db, endpoint.assetid, 50)
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.sourcekind == SOURCE_SERVICE
|
||||
|
||||
|
||||
def test_events_still_surface_when_nothing_is_structurally_wrong(db, endpoint):
|
||||
"""Demoting events below conditions must not mean discarding them."""
|
||||
_events(db, endpoint.assetid, 3)
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.sourcekind == SOURCE_EVENT
|
||||
assert result.label.endswith('x3')
|
||||
|
||||
|
||||
def test_events_outside_the_window_are_not_counted(db, endpoint):
|
||||
"""The window is a window, not a running total since install."""
|
||||
_events(db, endpoint.assetid, 5, hoursago=HEALTH_WINDOW_HOURS + 1)
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.level == 'ok'
|
||||
|
||||
|
||||
def test_a_stalled_service_is_distinguished_from_a_stopped_one(db, endpoint):
|
||||
"""stop-pending is not the same fault as cleanly down, and now says so."""
|
||||
endpoint.servicesjson = _services(
|
||||
{'name': 'eDNC', 'state': 'Stop Pending'})
|
||||
db.session.commit()
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.label == 'eDNC stalled'
|
||||
|
||||
|
||||
def test_malformed_services_reads_as_unknown_not_as_healthy(db, endpoint):
|
||||
"""Unparseable telemetry means we cannot tell - never claim ok."""
|
||||
endpoint.servicesjson = '{not json'
|
||||
db.session.commit()
|
||||
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
assert result.level == 'unknown'
|
||||
|
||||
|
||||
def test_a_ticking_event_count_is_the_same_condition(db, endpoint):
|
||||
"""x2 -> x3 must not churn history, reset firstseenat, or flap.
|
||||
|
||||
Identity is what and where, not the rendered label. If the label drove
|
||||
this, a crash loop would open a new transition every single check-in.
|
||||
"""
|
||||
_events(db, endpoint.assetid, 2)
|
||||
evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
db.session.add(EndpointEvent(
|
||||
assetid=endpoint.assetid, eventid=CRASH_EVENTID, eventrecordid=99,
|
||||
eventchannel='Application', eventtime=NOW - timedelta(hours=1)))
|
||||
db.session.commit()
|
||||
|
||||
# Still inside the 48h window: the condition genuinely persists, so this
|
||||
# isolates "same condition" from "events aged out".
|
||||
later = NOW + timedelta(hours=1)
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=later)
|
||||
db.session.commit()
|
||||
|
||||
assert result.label.endswith('x3')
|
||||
assert result.firstseenat == NOW
|
||||
assert EndpointHealthTransition.query.filter_by(
|
||||
assetid=endpoint.assetid).count() == 1
|
||||
|
||||
|
||||
def test_recovery_resolves_the_open_transition(db, endpoint):
|
||||
"""A condition is over only when something observes it to be over."""
|
||||
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Stopped'})
|
||||
db.session.commit()
|
||||
evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Running'})
|
||||
later = NOW + timedelta(hours=1)
|
||||
db.session.commit()
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint, now=later)
|
||||
db.session.commit()
|
||||
|
||||
assert result.level == 'ok'
|
||||
opening = (EndpointHealthTransition.query
|
||||
.filter_by(assetid=endpoint.assetid, sourcekind=SOURCE_SERVICE)
|
||||
.first())
|
||||
assert opening.resolvedat == later
|
||||
|
||||
|
||||
def test_transitions_are_capped_but_unresolved_rows_are_never_trimmed(
|
||||
db, endpoint):
|
||||
"""History is capped; the row explaining what is on screen still stays.
|
||||
|
||||
Flap the service past the high-water mark, then leave it down. Retention
|
||||
must bound the history AND keep the open transition, because that row is
|
||||
what the UI reads to say how long the current fault has been running.
|
||||
"""
|
||||
for index in range(TRANSITION_HIGHWATER + 6):
|
||||
running = index % 2 == 0
|
||||
endpoint.servicesjson = _services(
|
||||
{'name': 'eDNC', 'state': 'Running' if running else 'Stopped'})
|
||||
db.session.commit()
|
||||
evaluate_endpoint_health(
|
||||
endpoint.assetid, endpoint, now=NOW + timedelta(minutes=index))
|
||||
db.session.commit()
|
||||
|
||||
rows = EndpointHealthTransition.query.filter_by(
|
||||
assetid=endpoint.assetid).all()
|
||||
assert TRANSITION_RETENTION <= len(rows) <= TRANSITION_HIGHWATER
|
||||
|
||||
unresolved = [row for row in rows if row.resolvedat is None]
|
||||
assert len(unresolved) == 1
|
||||
|
||||
# Flapping is counted, and there is exactly one live verdict throughout.
|
||||
state = EndpointHealthState.query.filter_by(
|
||||
assetid=endpoint.assetid).one()
|
||||
assert state.flapcount > 1
|
||||
|
||||
|
||||
def test_lastconfirmedat_does_not_advance_without_an_observation(db, endpoint):
|
||||
"""A pinned blocker on a silent endpoint must not look freshly verified."""
|
||||
endpoint.servicesjson = _services({'name': 'eDNC', 'state': 'Stopped'})
|
||||
db.session.commit()
|
||||
evaluate_endpoint_health(endpoint.assetid, endpoint, now=NOW)
|
||||
db.session.commit()
|
||||
|
||||
# Re-evaluated two days later with no new check-in: lastreportedat is
|
||||
# untouched, so confirmation must not move either.
|
||||
result = evaluate_endpoint_health(endpoint.assetid, endpoint,
|
||||
now=NOW + timedelta(days=2))
|
||||
db.session.commit()
|
||||
|
||||
assert result.lastconfirmedat == NOW
|
||||
assert result.lastevaluatedat == NOW + timedelta(days=2)
|
||||
53
tests/test_plugins/test_endpoints_health_savepoint.py
Normal file
53
tests/test_plugins/test_endpoints_health_savepoint.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Health evaluation failing with a database error must not sink the check-in.
|
||||
|
||||
The health rollup is fail-open: a caught exception leaves the previous verdict
|
||||
standing. A caught IntegrityError from a flush, though, leaves the session
|
||||
needing a rollback, and the outer commit of the whole check-in would then fail.
|
||||
The evaluation runs inside a SAVEPOINT so only its own writes are discarded.
|
||||
"""
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if not atype:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset
|
||||
|
||||
|
||||
def test_integrity_error_in_health_keeps_the_checkin(client, db, app,
|
||||
monkeypatch):
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
from plugins.endpoints.models import EndpointState
|
||||
from plugins.endpoints.services import health
|
||||
|
||||
calls = []
|
||||
|
||||
def exploding_health(assetid, state, computertypeid=None):
|
||||
calls.append(assetid)
|
||||
# A duplicate unique value: the flush raises IntegrityError.
|
||||
db.session.add(AssetType(assettype='computer'))
|
||||
db.session.flush()
|
||||
|
||||
monkeypatch.setattr(health, 'evaluate_endpoint_health', exploding_health)
|
||||
|
||||
asset = _computer(db, 'SAVEPT01')
|
||||
plugin = EndpointsPlugin()
|
||||
plugin.apply_collector_payload({'hostname': 'SAVEPT01', 'services': []})
|
||||
db.session.commit()
|
||||
|
||||
assert calls == [asset.assetid]
|
||||
state = EndpointState.query.filter_by(assetid=asset.assetid).first()
|
||||
assert state is not None
|
||||
assert AssetType.query.filter_by(assettype='computer').count() == 1
|
||||
138
tests/test_plugins/test_endpoints_ip_history.py
Normal file
138
tests/test_plugins/test_endpoints_ip_history.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""IP History and IP Configuration, carved out of the old Details Network card.
|
||||
|
||||
These read core Communication rows, which accumulate as an ACCIDENTAL history:
|
||||
_record_interfaces matches an incoming NIC on MAC then on IP and inserts when
|
||||
neither matches, and never deletes. So an address the machine has not held for
|
||||
a year sits next to the current one. These tests pin the reading of that, not
|
||||
the collecting.
|
||||
|
||||
The date columns are the delicate part. modifieddate only moves when a field on
|
||||
the row actually changes, so an address still held and reported unchanged keeps
|
||||
an old stamp. It is labelled 'Last changed' for that reason and must not drift
|
||||
back to 'Last seen'.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.endpoints.api.routes import (
|
||||
_IPHISTORY_COLUMNS,
|
||||
_iphistory_status,
|
||||
_isostamp,
|
||||
)
|
||||
|
||||
|
||||
class FakeComm:
|
||||
"""Only the columns these two routes read."""
|
||||
|
||||
def __init__(self, ipaddress='10.0.0.1', macaddress='AA:BB',
|
||||
isprimary=False, ismachinenetwork=False, isdhcp=False,
|
||||
subnetmask='', gateway='', dns1='', dns2='',
|
||||
createddate=None, modifieddate=None):
|
||||
self.ipaddress = ipaddress
|
||||
self.macaddress = macaddress
|
||||
self.isprimary = isprimary
|
||||
self.ismachinenetwork = ismachinenetwork
|
||||
self.isdhcp = isdhcp
|
||||
self.subnetmask = subnetmask
|
||||
self.gateway = gateway
|
||||
self.dns1 = dns1
|
||||
self.dns2 = dns2
|
||||
self.createddate = createddate
|
||||
self.modifieddate = modifieddate
|
||||
|
||||
|
||||
def test_an_apipa_address_is_named_as_a_dhcp_failure():
|
||||
# 169.254.x.x is not an address the machine was given - it is what Windows
|
||||
# assigns when DHCP failed. Calling it 'Previous' would file a past network
|
||||
# fault as a past subnet.
|
||||
assert _iphistory_status(FakeComm(ipaddress='169.254.122.88')) == 'APIPA (no DHCP)'
|
||||
|
||||
|
||||
def test_apipa_wins_over_primary():
|
||||
# A box currently failing DHCP can have its APIPA address flagged primary
|
||||
# by the collector. The fault is the more useful thing to say.
|
||||
comm = FakeComm(ipaddress='169.254.1.1', isprimary=True)
|
||||
assert _iphistory_status(comm) == 'APIPA (no DHCP)'
|
||||
|
||||
|
||||
def test_a_normal_address_is_not_mistaken_for_apipa():
|
||||
# Guards a substring match: 169.254 must be a prefix, not found anywhere.
|
||||
assert _iphistory_status(FakeComm(ipaddress='10.169.254.5')) == 'Previous'
|
||||
|
||||
|
||||
def test_the_primary_address_is_marked():
|
||||
assert _iphistory_status(FakeComm(isprimary=True)) == 'Primary'
|
||||
|
||||
|
||||
def test_a_machine_network_address_is_distinguished_from_a_stale_one():
|
||||
# ismachinenetwork means it talks to the machine, not that it is old.
|
||||
assert _iphistory_status(FakeComm(ismachinenetwork=True)) == 'Machine network'
|
||||
|
||||
|
||||
def test_everything_else_reads_as_previous():
|
||||
assert _iphistory_status(FakeComm()) == 'Previous'
|
||||
|
||||
|
||||
def test_a_missing_timestamp_yields_null_not_a_crash():
|
||||
# createddate is nullable on legacy rows imported before BaseModel stamps.
|
||||
assert _isostamp(None) is None
|
||||
|
||||
|
||||
def test_a_timestamp_is_emitted_as_utc_iso():
|
||||
# Matches every other timestamp this file emits; the client formats it to
|
||||
# the viewer's locale rather than being pinned to the server's.
|
||||
stamp = _isostamp(datetime(2026, 9, 5, 11, 30, 0))
|
||||
assert stamp == '2026-09-05T11:30:00Z'
|
||||
|
||||
|
||||
def test_the_second_date_is_not_labelled_last_seen():
|
||||
# The whole reason for the label. modifieddate cannot support 'last seen':
|
||||
# an unchanged address reported daily keeps a months-old stamp.
|
||||
labels = {col['key']: col['label'] for col in _IPHISTORY_COLUMNS}
|
||||
assert labels['lastchanged'] == 'Last changed'
|
||||
assert 'seen' not in labels['lastchanged'].lower()
|
||||
assert labels['firstseen'] == 'First seen'
|
||||
|
||||
|
||||
def test_the_history_ordering_compiles_on_mysql():
|
||||
# Regression: the first cut ordered with .nullslast(), which is valid
|
||||
# ANSI/Postgres but blows up on MySQL with a 1064 syntax error. The test
|
||||
# suite runs on SQLite, so it passed and the page still 500'd in the
|
||||
# browser. Compile the real ordering against the MySQL dialect so the
|
||||
# dialect gap is caught here instead of in a container log.
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from shopdb.api import Communication
|
||||
|
||||
ordering = (select(Communication.ipaddress)
|
||||
.where(Communication.assetid == 1)
|
||||
.order_by(Communication.modifieddate.is_(None),
|
||||
Communication.modifieddate.desc(),
|
||||
Communication.communicationid.desc()))
|
||||
|
||||
compiled = str(ordering.compile(dialect=mysql.dialect()))
|
||||
assert 'NULLS LAST' not in compiled.upper()
|
||||
|
||||
|
||||
def test_every_panel_handler_still_has_a_route():
|
||||
# Regression: inserting the ipconfig route directly above panel_applications
|
||||
# ate that function's @endpoints_bp.route line, leaving a handler decorated
|
||||
# with require_permission only. It imported fine and every test passed; the
|
||||
# endpoint was simply gone. Assert each panel_* handler is actually mapped.
|
||||
import re
|
||||
|
||||
from plugins.endpoints.api import routes as routes_module
|
||||
|
||||
source = Path(routes_module.__file__).read_text(encoding='utf-8')
|
||||
handlers = re.findall(r'^def (panel_\w+)', source, re.MULTILINE)
|
||||
assert handlers, 'expected panel handlers in routes.py'
|
||||
|
||||
routed = set()
|
||||
for match in re.finditer(r'^@endpoints_bp\.route\(', source, re.MULTILINE):
|
||||
tail = source[match.start():]
|
||||
found = re.search(r'^def (\w+)', tail, re.MULTILINE)
|
||||
if found:
|
||||
routed.add(found.group(1))
|
||||
|
||||
assert set(handlers) - routed == set()
|
||||
49
tests/test_plugins/test_endpoints_link_label.py
Normal file
49
tests/test_plugins/test_endpoints_link_label.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""NIC link label: an unknown link must never read as a working one.
|
||||
|
||||
Link speed and duplex are paired onto the interface rows from a second WMI
|
||||
class, so three states are possible: read successfully, unreadable, and
|
||||
disconnected. Verified on a live box that a DISCONNECTED adapter returns empty
|
||||
Speed and FullDuplex - two of four NICs on the test machine were in that state.
|
||||
|
||||
That is the trap these tests pin. Defaulting an absent speed to 0 or an absent
|
||||
duplex to False would report every unplugged NIC as half duplex, which is the
|
||||
exact signature of the cable/port fault this field exists to catch. A column
|
||||
that cries fault on every disconnected NIC is a column nobody reads.
|
||||
"""
|
||||
|
||||
from plugins.endpoints.api.routes import _link_label
|
||||
|
||||
|
||||
def test_a_gigabit_full_duplex_link_is_not_annotated():
|
||||
"""The normal case should be quiet so the abnormal case stands out."""
|
||||
assert _link_label(1000000000, True) == '1 Gb'
|
||||
|
||||
|
||||
def test_half_duplex_is_called_out_because_it_is_the_actionable_case():
|
||||
assert _link_label(100000000, False) == '100 Mb half duplex'
|
||||
|
||||
|
||||
def test_a_disconnected_adapter_reads_as_unknown_not_half_duplex():
|
||||
"""The live-box case: disconnected NICs return empty speed and duplex.
|
||||
|
||||
Reading this as half duplex would flag every unplugged NIC as a fault.
|
||||
"""
|
||||
assert _link_label(None, None) == '-'
|
||||
|
||||
|
||||
def test_an_unreadable_duplex_on_a_live_link_is_not_assumed_half():
|
||||
"""Speed known, duplex not. Unknown duplex must not render as a fault."""
|
||||
assert _link_label(10000000000, None) == '10 Gb'
|
||||
|
||||
|
||||
def test_zero_speed_is_unknown_rather_than_a_zero_speed_link():
|
||||
assert _link_label(0, True) == '-'
|
||||
|
||||
|
||||
def test_sub_gigabit_speeds_stay_in_megabits():
|
||||
assert _link_label(10000000, True) == '10 Mb'
|
||||
|
||||
|
||||
def test_speeds_do_not_render_trailing_zeros():
|
||||
"""2.5Gb NICs are common now; the label should not read '2.5000 Gb'."""
|
||||
assert _link_label(2500000000, True) == '2.5 Gb'
|
||||
186
tests/test_plugins/test_endpoints_mapped_drives.py
Normal file
186
tests/test_plugins/test_endpoints_mapped_drives.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Mapped-drive merge: two disagreeing Windows sources into one honest row.
|
||||
|
||||
Windows reports mapped drives twice - Win32_NetworkConnection (the 'net use'
|
||||
table) and Get-SmbMapping - and the two do not agree. Captured on one machine at
|
||||
one moment, same two mappings, Get-SmbMapping said OK for both while net use
|
||||
said Disconnected and Reconnecting. Trusting the structured source alone would
|
||||
have rendered a healthy Q: on a box whose Q: was down.
|
||||
|
||||
So the probe collects both without reconciling them and _mapped_drives() decides
|
||||
what an operator is shown. Every test here pins a way that decision could
|
||||
silently start rendering a broken drive as a working one, which is the only
|
||||
failure mode of this card that matters: nobody reports a drive that looks fine.
|
||||
"""
|
||||
|
||||
from plugins.endpoints.api.routes import _mapped_drives
|
||||
|
||||
|
||||
def _netuse(localname, remotename, status):
|
||||
return {'localname': localname, 'remotename': remotename, 'status': status}
|
||||
|
||||
|
||||
def _smb(localpath, remotepath, status):
|
||||
return {'localpath': localpath, 'remotepath': remotepath, 'status': status}
|
||||
|
||||
|
||||
def test_the_worse_of_two_disagreeing_sources_is_what_gets_shown():
|
||||
"""The real capture: Get-SmbMapping says OK, net use says Disconnected.
|
||||
|
||||
The drive was down. Showing OK because one source said so is the exact bug
|
||||
this merge exists to prevent.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('Q:', r'\\server\specdata', 'Disconnected')],
|
||||
'smbmapping': [_smb('Q:', r'\\server\specdata', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'Q: \\server\specdata (Disconnected)']
|
||||
|
||||
|
||||
def test_reconnecting_is_worse_than_ok_and_survives_the_merge():
|
||||
"""Reconnecting is a real status from the capture, not a synonym for OK."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('S:', r'\\server\shared', 'Reconnecting')],
|
||||
'smbmapping': [_smb('S:', r'\\server\shared', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'S: \\server\shared (Reconnecting)']
|
||||
|
||||
|
||||
def test_a_drive_both_sources_call_healthy_is_not_annotated():
|
||||
"""Only problems are labelled; tagging every drive '(OK)' buries the bad one."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('H:', r'\\server\home', 'OK')],
|
||||
'smbmapping': [_smb('H:', r'\\server\home', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'H: \\server\home']
|
||||
|
||||
|
||||
def test_a_drive_only_one_source_knows_about_is_still_reported():
|
||||
"""Get-SmbMapping is absent on some builds and session types.
|
||||
|
||||
Requiring agreement between the sources would hide every mapped drive on
|
||||
those machines rather than showing what is known about them.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('P:', r'\\server\programs', 'Disconnected')],
|
||||
'smbmapping': [],
|
||||
})
|
||||
|
||||
assert rows == [r'P: \\server\programs (Disconnected)']
|
||||
|
||||
|
||||
def test_the_smbmapping_key_missing_entirely_degrades_to_netuse():
|
||||
"""Old agents predate the second source; the panel must still work.
|
||||
|
||||
The server ships ahead of the agent republish, so this is the live shape
|
||||
until every endpoint has the new probe.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('Q:', r'\\server\specdata', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'Q: \\server\specdata']
|
||||
|
||||
|
||||
def test_a_drive_with_no_status_from_either_source_reads_as_unknown():
|
||||
"""A blank annotation reads as healthy, so a failed check must not be blank."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('Z:', r'\\server\scratch', None)],
|
||||
'smbmapping': [],
|
||||
})
|
||||
|
||||
assert rows == [r'Z: \\server\scratch (Unknown)']
|
||||
|
||||
|
||||
def test_an_unrecognised_status_is_not_assumed_healthy():
|
||||
"""Unknown vocabulary ranks below OK deliberately.
|
||||
|
||||
Paired against OK specifically: a status the server does not recognise must
|
||||
LOSE to a healthy one, or an unseen fault renders green. Pairing it against
|
||||
a known-bad status would prove nothing, since the known-bad value would win
|
||||
however unknowns are ranked.
|
||||
"""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('X:', r'\\server\thing', 'SomeNewStatus')],
|
||||
'smbmapping': [_smb('X:', r'\\server\thing', 'OK')],
|
||||
})
|
||||
|
||||
assert rows == [r'X: \\server\thing (SomeNewStatus)']
|
||||
|
||||
|
||||
def test_a_known_bad_status_beats_an_unrecognised_one():
|
||||
"""Unavailable outranks an unknown string; the worst named state wins."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [_netuse('X:', r'\\server\thing', 'Unavailable')],
|
||||
'smbmapping': [_smb('X:', r'\\server\thing', 'SomeNewStatus')],
|
||||
})
|
||||
|
||||
assert rows == [r'X: \\server\thing (Unavailable)']
|
||||
|
||||
|
||||
def test_which_source_reported_a_status_does_not_change_the_verdict():
|
||||
"""The same pair of statuses must resolve identically whichever side sent it.
|
||||
|
||||
_worse_drive_status ranks by severity and breaks ties by argument order, so
|
||||
if two statuses ever rank EQUAL the answer silently depends on which source
|
||||
happened to report which - and a drive would read healthy or broken based on
|
||||
enumeration order. Asserting both directions is what makes an accidental tie
|
||||
visible; a single-direction test passes on luck.
|
||||
"""
|
||||
remote = r'\\server\thing'
|
||||
pairs = [
|
||||
('OK', 'Disconnected'),
|
||||
('OK', 'SomeNewStatus'),
|
||||
('Reconnecting', 'Unavailable'),
|
||||
('OK', 'Reconnecting'),
|
||||
]
|
||||
|
||||
for first, second in pairs:
|
||||
forward = _mapped_drives({
|
||||
'netuse': [_netuse('X:', remote, first)],
|
||||
'smbmapping': [_smb('X:', remote, second)],
|
||||
})
|
||||
backward = _mapped_drives({
|
||||
'netuse': [_netuse('X:', remote, second)],
|
||||
'smbmapping': [_smb('X:', remote, first)],
|
||||
})
|
||||
assert forward == backward, (
|
||||
'statuses {!r}/{!r} resolve differently depending on which source '
|
||||
'reported them'.format(first, second))
|
||||
|
||||
|
||||
def test_the_two_sources_are_paired_by_drive_letter_not_by_order():
|
||||
"""The sources enumerate independently and need not agree on ordering."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': [
|
||||
_netuse('Q:', r'\\server\specdata', 'OK'),
|
||||
_netuse('S:', r'\\server\shared', 'OK'),
|
||||
],
|
||||
'smbmapping': [
|
||||
_smb('S:', r'\\server\shared', 'Disconnected'),
|
||||
_smb('Q:', r'\\server\specdata', 'OK'),
|
||||
],
|
||||
})
|
||||
|
||||
assert rows == [
|
||||
r'Q: \\server\specdata',
|
||||
r'S: \\server\shared (Disconnected)',
|
||||
]
|
||||
|
||||
|
||||
def test_no_user_context_at_all_yields_no_rows():
|
||||
"""Nobody signed in is a valid state, not a fault."""
|
||||
assert _mapped_drives({}) == []
|
||||
|
||||
|
||||
def test_malformed_entries_are_skipped_rather_than_raising():
|
||||
"""The payload is agent-supplied; a bad row must not blank the whole card."""
|
||||
rows = _mapped_drives({
|
||||
'netuse': ['not-a-dict', None, {'status': 'OK'},
|
||||
_netuse('Q:', r'\\server\specdata', 'Disconnected')],
|
||||
'smbmapping': 'not-a-list',
|
||||
})
|
||||
|
||||
assert rows == [r'Q: \\server\specdata (Disconnected)']
|
||||
118
tests/test_plugins/test_endpoints_mapped_drives_card.py
Normal file
118
tests/test_plugins/test_endpoints_mapped_drives_card.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""The mapped drives card: structured rows, badges, and row order.
|
||||
|
||||
The merge logic itself is pinned in test_endpoints_mapped_drives.py. These
|
||||
tests cover what the CARD adds on top: a per-drive badge colour and the row
|
||||
order that decides what an operator sees first.
|
||||
|
||||
The failure mode this guards is silent: a broken drive rendered green, or
|
||||
sorted below the fold, leaves the card looking healthy and tells nobody.
|
||||
"""
|
||||
from plugins.endpoints.api.routes import (
|
||||
_mapped_drive_rows, _mapped_drives, _mapped_drives_unhealthy_first,
|
||||
)
|
||||
|
||||
|
||||
def _context(netuse=None, smbmapping=None):
|
||||
context = {}
|
||||
if netuse is not None:
|
||||
context['netuse'] = netuse
|
||||
if smbmapping is not None:
|
||||
context['smbmapping'] = smbmapping
|
||||
return context
|
||||
|
||||
|
||||
def _netuse(local, remote, status):
|
||||
return {'localname': local, 'remotename': remote, 'status': status}
|
||||
|
||||
|
||||
def test_a_healthy_drive_is_green():
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
|
||||
assert rows[0]['statecolor'] == 'green'
|
||||
assert rows[0]['statuslabel'] == 'OK'
|
||||
|
||||
|
||||
def test_a_disconnected_drive_is_red():
|
||||
rows = _mapped_drive_rows(
|
||||
_context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')]))
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_connected_counts_as_healthy_alongside_ok():
|
||||
"""net use and Get-SmbMapping use different words for the same state."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Connected')]))
|
||||
assert rows[0]['statecolor'] == 'green'
|
||||
|
||||
|
||||
def test_a_drive_with_no_status_is_red_not_green():
|
||||
"""An uncoloured row reads as working. Unknown health is not health."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', None)]))
|
||||
assert rows[0]['statuslabel'] == 'Unknown'
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_an_unrecognised_status_is_not_coloured_green():
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\s', 'Weird')]))
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_the_worse_of_two_sources_drives_the_badge_colour():
|
||||
"""The merge already picks the worse status; the badge must follow it."""
|
||||
rows = _mapped_drive_rows(_context(
|
||||
netuse=[_netuse('Z:', r'\\srv\share', 'OK')],
|
||||
smbmapping=[{'localpath': 'Z:', 'remotepath': r'\\srv\share',
|
||||
'status': 'Disconnected'}],
|
||||
))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['statecolor'] == 'red'
|
||||
|
||||
|
||||
def test_the_remote_path_is_carried_as_its_own_field():
|
||||
"""The card shows the path separately rather than glued into the title."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')]))
|
||||
assert rows[0]['drive'] == 'Z:'
|
||||
assert rows[0]['remote'] == r'\\srv\share'
|
||||
|
||||
|
||||
def test_the_summary_line_and_the_card_agree_about_a_broken_drive():
|
||||
"""Both are built from the same rows, so they cannot drift apart."""
|
||||
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'Disconnected')])
|
||||
assert _mapped_drive_rows(context)[0]['statecolor'] == 'red'
|
||||
assert '(Disconnected)' in _mapped_drives(context)[0]
|
||||
|
||||
|
||||
def test_a_healthy_drive_is_not_annotated_in_the_summary_line():
|
||||
context = _context(netuse=[_netuse('Z:', r'\\srv\share', 'OK')])
|
||||
assert _mapped_drives(context) == [r'Z: \\srv\share']
|
||||
|
||||
|
||||
def test_no_user_context_yields_no_rows():
|
||||
assert _mapped_drive_rows({}) == []
|
||||
|
||||
|
||||
def test_broken_drives_sort_above_healthy_ones():
|
||||
"""The broken one must be on screen without scrolling past nine good ones."""
|
||||
rows = _mapped_drive_rows(_context(netuse=[
|
||||
_netuse('H:', r'\\srv\home', 'OK'),
|
||||
_netuse('S:', r'\\srv\shared', 'OK'),
|
||||
_netuse('Z:', r'\\srv\dead', 'Disconnected'),
|
||||
]))
|
||||
ordered = _mapped_drives_unhealthy_first(rows)
|
||||
assert [row['drive'] for row in ordered] == ['Z:', 'H:', 'S:']
|
||||
|
||||
|
||||
def test_drive_letter_order_survives_within_each_health_group():
|
||||
"""Sorting only on health keeps the list from reshuffling on refresh.
|
||||
|
||||
The unhealthy drives here carry DIFFERENT status words, chosen so that
|
||||
sorting on the status text would reorder them (Disconnected before
|
||||
Unavailable) against drive-letter order. A secondary sort key would pass
|
||||
a test where every broken drive said the same thing.
|
||||
"""
|
||||
rows = _mapped_drive_rows(_context(netuse=[
|
||||
_netuse('A:', r'\\srv\a', 'OK'),
|
||||
_netuse('B:', r'\\srv\b', 'Unavailable'),
|
||||
_netuse('C:', r'\\srv\c', 'Connected'),
|
||||
_netuse('D:', r'\\srv\d', 'Disconnected'),
|
||||
]))
|
||||
ordered = _mapped_drives_unhealthy_first(rows)
|
||||
assert [row['drive'] for row in ordered] == ['B:', 'D:', 'A:', 'C:']
|
||||
@@ -3,9 +3,11 @@
|
||||
The tray captures a screenshot and a free-text note; the agent posts them to
|
||||
POST /api/endpoints/operatorreports on the collector key. The bytes land on
|
||||
disk under the operator-reports uploads dir and the row holds metadata. The
|
||||
human-facing panel and image-serve routes are JWT + endpoints.view gated, and
|
||||
the serve route carries the inert-upload headers. A retention sweep purges rows
|
||||
and files older than the configured window.
|
||||
human-facing panel and image-serve routes are JWT + endpoints.operatorreports
|
||||
gated - deliberately NOT endpoints.view, because the screenshot is an
|
||||
uncontrolled capture of the operator's screen and reading telemetry does not
|
||||
imply a right to see it - and the serve route carries the inert-upload headers.
|
||||
A retention sweep purges rows and files older than the configured window.
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -283,3 +285,101 @@ def test_upload_refuses_an_image_over_the_cap(client, db, app):
|
||||
assert 'limit' in response.get_json()['data']['error']['message']
|
||||
assert OperatorReport.query.filter_by(
|
||||
reportedhostname='OPREPORT-HUGE').first() is None
|
||||
|
||||
|
||||
def _viewer_headers(client, db, permissions, username):
|
||||
"""Log in a user holding exactly `permissions` as direct grants.
|
||||
|
||||
Deliberately direct grants rather than a role: endpoints.operatorreports is
|
||||
in the scope denylist, so a role can never confer it and a role-based
|
||||
fixture could not express the case these tests need.
|
||||
"""
|
||||
from shopdb.core.models import Permission, User
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
user = User(username=username, email=f'{username}@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
for name in permissions:
|
||||
perm = Permission.query.filter_by(name=name).first()
|
||||
if perm is None:
|
||||
perm = Permission(name=name, description=name,
|
||||
category='endpoints')
|
||||
db.session.add(perm)
|
||||
db.session.flush()
|
||||
user.directpermissions.append(perm)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _report_for(client, db, app, hostname):
|
||||
"""Upload one report on the collector key and return its row."""
|
||||
from plugins.endpoints.models import OperatorReport
|
||||
|
||||
asset = _computer(db, hostname)
|
||||
client.post(UPLOAD_URL,
|
||||
data={'hostname': hostname, 'details': 'x',
|
||||
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
|
||||
content_type='multipart/form-data',
|
||||
headers=_collector_headers(app))
|
||||
return OperatorReport.query.filter_by(assetid=asset.assetid).first()
|
||||
|
||||
|
||||
def test_endpoints_view_alone_cannot_fetch_a_report_screenshot(
|
||||
client, db, app):
|
||||
"""The whole point of the split: telemetry access is not screenshot access.
|
||||
|
||||
The screenshot is an uncontrolled capture of the operator's screen, so a
|
||||
user who can read a PC's telemetry must NOT get the image for free. If this
|
||||
regresses, sensitive shopfloor content is exposed to every endpoint viewer.
|
||||
"""
|
||||
report = _report_for(client, db, app, 'OPREPORT-VIEWONLY')
|
||||
headers = _viewer_headers(client, db, ['endpoints.view'], 'telemetryonly')
|
||||
|
||||
response = client.get(IMAGE_URL.format(report.operatorreportid),
|
||||
headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_endpoints_view_alone_cannot_list_operator_reports(client, db, app):
|
||||
"""The panel route leaks the report text and filenames, so it is gated too.
|
||||
|
||||
Hiding the tab in the UI is not a control; the route must refuse directly.
|
||||
"""
|
||||
report = _report_for(client, db, app, 'OPREPORT-PANELDENY')
|
||||
headers = _viewer_headers(client, db, ['endpoints.view'], 'panelviewonly')
|
||||
|
||||
response = client.get(PANEL_URL.format(report.assetid), headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_the_operatorreports_grant_opens_both_routes(client, db, app):
|
||||
"""The positive case: the dedicated grant is sufficient on its own."""
|
||||
report = _report_for(client, db, app, 'OPREPORT-GRANTED')
|
||||
headers = _viewer_headers(
|
||||
client, db, ['endpoints.view', 'endpoints.operatorreports'],
|
||||
'reportviewer')
|
||||
|
||||
image = client.get(IMAGE_URL.format(report.operatorreportid),
|
||||
headers=headers)
|
||||
assert image.status_code == 200
|
||||
|
||||
panel = client.get(PANEL_URL.format(report.assetid), headers=headers)
|
||||
assert panel.status_code == 200
|
||||
reports = panel.get_json()['data']['reports']
|
||||
assert len(reports) == 1
|
||||
|
||||
|
||||
def test_operator_reports_panel_declares_its_permission():
|
||||
"""The panel carries the grant so the tab is not advertised without it."""
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
panels = EndpointsPlugin().get_asset_panels()
|
||||
panel = next(p for p in panels
|
||||
if p.get('render') == 'operatorreports')
|
||||
assert panel['permission'] == 'endpoints.operatorreports'
|
||||
|
||||
74
tests/test_plugins/test_endpoints_panel_groups.py
Normal file
74
tests/test_plugins/test_endpoints_panel_groups.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""The Monitors tab split: a panel in a group no tab renders disappears.
|
||||
|
||||
This has silently eaten cards twice - once when tracked services moved out of
|
||||
'actions', once on the 'metrics' to 'monitors' rename. Both times the backend
|
||||
named a group the frontend did not render, and the panel was not shown, not
|
||||
logged, and not missed until someone went looking for it.
|
||||
|
||||
The split is only correct if four places agree, and only two of them are Python:
|
||||
1. the panels' group here
|
||||
2. RENDERED_GROUPS in PluginAssetPanels.vue
|
||||
3. the tab label map in PCDetail.vue
|
||||
4. the v-show block that gives the tab a layout
|
||||
|
||||
These tests pin the Python half so a later edit cannot quietly move a panel into
|
||||
a group nothing renders. The frontend half is pinned by its own spec.
|
||||
"""
|
||||
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
from shopdb.core.api.pluginui import ASSET_PANEL_GROUPS
|
||||
|
||||
|
||||
# Derived from the server tuple rather than hand-copied. This used to be a
|
||||
# literal set duplicating RENDERED_GROUPS, which made it a FIFTH copy of the
|
||||
# vocabulary and one more thing to forget: it passed for a group the server
|
||||
# would rewrite to 'details' anyway. ASSET_PANEL_GROUPS is the value that
|
||||
# actually decides what reaches the client, and panelGroups.spec.js pins it
|
||||
# against the frontend copy.
|
||||
RENDERED_GROUPS = set(ASSET_PANEL_GROUPS)
|
||||
|
||||
|
||||
def _panels():
|
||||
return {panel['id']: panel for panel in EndpointsPlugin().get_asset_panels()}
|
||||
|
||||
|
||||
def test_no_panel_lands_in_a_group_the_frontend_does_not_render():
|
||||
"""The failure mode that ate cards twice: a group with no tab."""
|
||||
for panel in EndpointsPlugin().get_asset_panels():
|
||||
group = panel.get('group', 'details')
|
||||
assert group in RENDERED_GROUPS, (
|
||||
f"panel {panel['id']} is in group {group!r}, which no tab renders"
|
||||
)
|
||||
|
||||
|
||||
def test_tracked_services_and_processes_moved_to_their_own_group():
|
||||
panels = _panels()
|
||||
|
||||
assert panels['endpoints-tracked-services']['group'] == 'serviceprocess'
|
||||
assert panels['endpoints-tracked-processes']['group'] == 'serviceprocess'
|
||||
|
||||
|
||||
def test_the_service_process_tab_is_not_left_empty():
|
||||
"""A tab whose panels all moved away reads as broken, not as absent."""
|
||||
groups = [panel.get('group', 'details')
|
||||
for panel in EndpointsPlugin().get_asset_panels()]
|
||||
|
||||
assert 'serviceprocess' in groups
|
||||
|
||||
|
||||
def test_system_monitors_keeps_panels_of_its_own():
|
||||
"""The rename is only safe if Monitors still has content after the move."""
|
||||
groups = [panel.get('group', 'details')
|
||||
for panel in EndpointsPlugin().get_asset_panels()]
|
||||
|
||||
assert 'monitors' in groups
|
||||
|
||||
|
||||
def test_disk_health_stayed_with_the_system_health_panels():
|
||||
"""Disk health is the box's own health, not something running on it.
|
||||
|
||||
It is now a section of the Storage card rather than a panel of its own,
|
||||
so the group assertion follows it there.
|
||||
"""
|
||||
assert 'endpoints-diskhealth' not in _panels()
|
||||
assert _panels()['endpoints-storage']['group'] == 'monitors'
|
||||
@@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
|
||||
from plugins.endpoints.commands import (
|
||||
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
|
||||
requires_target,
|
||||
requires_target, minimum_agent_version_for, agent_meets_minimum,
|
||||
parse_agent_version,
|
||||
)
|
||||
|
||||
|
||||
@@ -69,12 +70,12 @@ def test_endpoints_asset_panels_are_computer_scoped():
|
||||
"""Every asset panel targets computer assets only, with an endpoint."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
assert len(panels) == 7
|
||||
assert len(panels) == 14
|
||||
for panel in panels:
|
||||
assert panel['assettypes'] == ['computer']
|
||||
assert panel['endpoint'].startswith('/api/endpoints/')
|
||||
assert '{assetid}' in panel['endpoint']
|
||||
assert panel['render'] in ('keyvalue', 'list', 'table', 'operatorreports')
|
||||
assert panel['render'] in ('keyvalue', 'list', 'table', 'tabs', 'operatorreports')
|
||||
|
||||
|
||||
def test_services_and_processes_are_split_into_two_filtered_cards():
|
||||
@@ -177,6 +178,100 @@ def test_collect_now_is_allowlisted_targetless_and_routine():
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_collect_now_survives_a_policy_that_never_mentions_it():
|
||||
"""Refresh Status must not be narrowed away by an unrelated policy.
|
||||
|
||||
A non-empty effective policy skips the permissive fallback in
|
||||
ALLOWED_ACTIONS_FOR, so any action the policy does not name is intersected
|
||||
out. collect_now only asks an agent to report state it already has, so
|
||||
Configuration Policy is not the authority on it - without the exemption
|
||||
every policied endpoint refused Refresh Status with "Action not permitted
|
||||
by policy", a denial the operator could not act on because the policy was
|
||||
never about that action.
|
||||
"""
|
||||
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
|
||||
|
||||
assert 'collect_now' in POLICY_EXEMPT_ACTIONS
|
||||
# Exemption from POLICY is not exemption from PERMISSION: it stays gated by
|
||||
# the base endpoints.command grant, which is what keeps guests out.
|
||||
assert elevated_permission_for('collect_now') is None
|
||||
|
||||
|
||||
def test_collect_log_survives_a_policy_that_never_mentions_it():
|
||||
"""Log collection must not be narrowed away by an unrelated policy.
|
||||
|
||||
Same intersection trap as collect_now, but with a sharper failure: every
|
||||
existing policy predates this action and so cannot name it, meaning any
|
||||
endpoint carrying a policy refused log collection outright - including for
|
||||
an admin, who reads "requires permission" and goes looking at a grant they
|
||||
already hold. Logs are also what an operator reaches for when an endpoint
|
||||
is already misbehaving, so a policy gate here withholds the evidence
|
||||
exactly when it is wanted.
|
||||
"""
|
||||
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
|
||||
|
||||
assert 'collect_log' in POLICY_EXEMPT_ACTIONS
|
||||
# Exemption from POLICY is not exemption from PERMISSION. Unlike
|
||||
# collect_now this one DOES carry an elevated grant, so the assertion is
|
||||
# that the grant survived the exemption rather than being loosened by it.
|
||||
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
|
||||
|
||||
|
||||
def test_collect_log_declares_the_agent_version_that_implements_it():
|
||||
"""collect_log names its minimum agent version.
|
||||
|
||||
An agent older than the action has no case for it and falls through to its
|
||||
unknown-action default, answering "Unknown or disallowed action" - which
|
||||
reads as an authorization refusal and sends the operator to check a grant
|
||||
they already hold. The floor is what lets the server refuse first with the
|
||||
actionable answer instead.
|
||||
"""
|
||||
assert minimum_agent_version_for('collect_log') == '1.16.0'
|
||||
# Actions that every shipped agent understands must NOT declare a floor,
|
||||
# or the guard starts refusing endpoints that are perfectly capable.
|
||||
assert minimum_agent_version_for('collect_now') is None
|
||||
assert minimum_agent_version_for('system_restart') is None
|
||||
|
||||
|
||||
def test_agent_version_comparison_survives_real_fleet_version_strings():
|
||||
"""Version comparison is numeric per part, not lexical.
|
||||
|
||||
Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
|
||||
while the csproj and binary store carry three ('1.16.0'), so the same build
|
||||
must compare equal across both spellings. And a lexical compare ranks
|
||||
'1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
|
||||
old agent past the guard, the exact failure the guard exists to prevent.
|
||||
"""
|
||||
assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
|
||||
assert agent_meets_minimum('1.16.0', '1.16.0')
|
||||
assert agent_meets_minimum('1.16.0.0', '1.16.0')
|
||||
assert agent_meets_minimum('1.17.0.0', '1.16.0')
|
||||
assert agent_meets_minimum('2.0.0.0', '1.16.0')
|
||||
assert not agent_meets_minimum('1.15.0.0', '1.16.0')
|
||||
assert not agent_meets_minimum('1.11.0.0', '1.16.0')
|
||||
# The lexical trap.
|
||||
assert not agent_meets_minimum('1.9.0.0', '1.16.0')
|
||||
|
||||
|
||||
def test_an_unreadable_agent_version_fails_the_guard_closed():
|
||||
"""An unparseable or absent version does not satisfy a minimum.
|
||||
|
||||
Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
|
||||
endpoint reports nothing at all. There is no honest ordering between a
|
||||
placeholder and a real build, so the guard refuses rather than guessing.
|
||||
Letting it through would only move the failure to the agent, where the
|
||||
error message is the misleading one.
|
||||
"""
|
||||
assert parse_agent_version('0.0.0-sample') is None
|
||||
assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
|
||||
assert not agent_meets_minimum(None, '1.16.0')
|
||||
assert not agent_meets_minimum('', '1.16.0')
|
||||
# No declared floor means no opinion: an action every agent implements must
|
||||
# stay available even to an endpoint whose version we cannot read.
|
||||
assert agent_meets_minimum('0.0.0-sample', None)
|
||||
assert agent_meets_minimum(None, None)
|
||||
|
||||
|
||||
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||
"""The two software-delivery actions are allowlisted with a targetkind so
|
||||
the enqueue route accepts them and carries their subject on the wire:
|
||||
@@ -189,19 +284,36 @@ def test_run_script_and_update_app_are_allowlisted_delivery_actions():
|
||||
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
|
||||
|
||||
|
||||
def test_delivery_actions_require_the_elevated_deploy_permission():
|
||||
"""run_script and update_app both demand endpoints.deploy ON TOP OF
|
||||
endpoints.command, so pushing software is a strictly heavier grant than a
|
||||
routine service/process toggle."""
|
||||
assert elevated_permission_for('run_script') == 'endpoints.deploy'
|
||||
assert elevated_permission_for('update_app') == 'endpoints.deploy'
|
||||
def test_big_four_actions_each_require_their_own_elevated_permission():
|
||||
"""The four scope-gated actions map to four INDEPENDENT permissions.
|
||||
|
||||
They previously shared endpoints.deploy, which meant granting an operator
|
||||
application updates also granted arbitrary SYSTEM code execution through
|
||||
run_script. Keeping these distinct is the whole point of the split, so a
|
||||
regression that re-bundles them must fail here.
|
||||
"""
|
||||
assert elevated_permission_for('system_restart') == 'endpoints.restart'
|
||||
assert elevated_permission_for('run_script') == 'endpoints.runscript'
|
||||
assert elevated_permission_for('update_app') == 'endpoints.updateapp'
|
||||
assert elevated_permission_for('scheduledtask_run') == 'endpoints.corptools'
|
||||
granted = {
|
||||
elevated_permission_for(action)
|
||||
for action in ('system_restart', 'run_script', 'update_app',
|
||||
'scheduledtask_run')
|
||||
}
|
||||
assert len(granted) == 4
|
||||
|
||||
|
||||
def test_endpoints_declares_the_deploy_permission():
|
||||
"""The dedicated software-delivery permission is declared for role seeding."""
|
||||
def test_endpoints_declares_the_split_delivery_permissions():
|
||||
"""Each split grant is declared so role seeding can offer it, and the
|
||||
retired bundle is gone so the role grid stops offering a dead permission."""
|
||||
plugin = EndpointsPlugin()
|
||||
permissions = {name for name, *_ in plugin.get_permissions()}
|
||||
assert 'endpoints.deploy' in permissions
|
||||
assert 'endpoints.runscript' in permissions
|
||||
assert 'endpoints.updateapp' in permissions
|
||||
assert 'endpoints.corptools' in permissions
|
||||
assert 'endpoints.restart' in permissions
|
||||
assert 'endpoints.deploy' not in permissions
|
||||
|
||||
|
||||
def test_known_payload_keys_cover_persisted_and_relayed_fields():
|
||||
@@ -220,18 +332,21 @@ def test_known_payload_keys_cover_persisted_and_relayed_fields():
|
||||
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
|
||||
|
||||
|
||||
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
|
||||
"""Disk/bios/memory inventory is collected at the agent before the server
|
||||
persists it. Those keys must be OUTSIDE the known set so ingest logs them
|
||||
rather than dropping them silently."""
|
||||
def test_hardware_telemetry_keys_are_persisted_not_merely_logged():
|
||||
"""Disk/bios/memory/processor/osinfo inventory was collected at the agent and
|
||||
logged as unknown keys for several versions while the shapes were proven
|
||||
against real endpoint traffic. They have columns now (endpoints migration
|
||||
0013), so they must be INSIDE the known set - leaving them out would log
|
||||
every report as carrying unpersisted keys while the ingest quietly stores
|
||||
them, which is the most misleading combination available."""
|
||||
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
|
||||
|
||||
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
|
||||
assert key not in KNOWN_PAYLOAD_KEYS
|
||||
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
|
||||
'pendingreboot', 'usercontext'):
|
||||
assert key in KNOWN_PAYLOAD_KEYS
|
||||
|
||||
# 'applications' USED to be in this list. It is persisted now - that is what
|
||||
# the installed-software work added - so it belongs in the known set, and
|
||||
# asserting its absence would fail the moment the feature it tests works.
|
||||
# 'applications' followed this same staging path earlier and is likewise
|
||||
# persisted, to its own endpointapplications table rather than a state blob.
|
||||
assert 'applications' in KNOWN_PAYLOAD_KEYS
|
||||
|
||||
|
||||
@@ -287,13 +402,26 @@ def test_fleet_list_route_resolves_without_trailing_slash():
|
||||
# Dockerfile, the compose files or the Windows deploy pins TZ.
|
||||
|
||||
def _in_zone(zonename, call):
|
||||
"""Run call() with the process in a named zone, then put it back."""
|
||||
"""Run call() with the process in a named zone, then put it back.
|
||||
|
||||
time.tzset() is POSIX-only and absent on Windows, where the process zone
|
||||
cannot be moved this way at all. Rather than fail three tests permanently on
|
||||
every Windows checkout - which teaches the developer to ignore a red suite -
|
||||
the shift is skipped there and the assertions still run in whatever zone the
|
||||
machine is in. That is weaker locally but not worthless: _parse_dt must
|
||||
return the same UTC answer in EVERY zone, so the assertion is correct in all
|
||||
of them, and CI runs on Linux where the real shift happens.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
tzset = getattr(time, 'tzset', None)
|
||||
if tzset is None:
|
||||
return call()
|
||||
|
||||
before = os.environ.get('TZ')
|
||||
os.environ['TZ'] = zonename
|
||||
time.tzset()
|
||||
tzset()
|
||||
try:
|
||||
return call()
|
||||
finally:
|
||||
@@ -301,7 +429,7 @@ def _in_zone(zonename, call):
|
||||
os.environ.pop('TZ', None)
|
||||
else:
|
||||
os.environ['TZ'] = before
|
||||
time.tzset()
|
||||
tzset()
|
||||
|
||||
|
||||
def test_a_utc_timestamp_survives_a_non_utc_server():
|
||||
|
||||
923
tests/test_plugins/test_endpoints_policy_override.py
Normal file
923
tests/test_plugins/test_endpoints_policy_override.py
Normal file
@@ -0,0 +1,923 @@
|
||||
"""Admin break-glass over policy gating, and the holds that make it stick.
|
||||
|
||||
Policy gating is right almost always and wrong at 3am. These tests pin the
|
||||
override's boundaries: it must be explicit, admin-only, reasoned, audited, and
|
||||
it must NOT become a way to acquire an elevated permission the caller lacks.
|
||||
|
||||
The hold half exists because an override on a desired-mode action is a fight
|
||||
with the reconciler - without suppression the next check-in re-enqueues the
|
||||
opposite command and the override looks broken.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
|
||||
def _enable_policies(db):
|
||||
if not Setting.query.filter_by(key='computers_configpolicies_enabled').first():
|
||||
db.session.add(Setting(key='computers_configpolicies_enabled',
|
||||
value='true'))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _endpoint(db, hostname='holdbox'):
|
||||
"""An Asset + Computer pair, the minimum a policy can resolve against."""
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
# The enqueue route 404s without a reported state, so the endpoint has to
|
||||
# look like it has checked in at least once.
|
||||
from plugins.endpoints.models import EndpointState
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _login(client, username, password='testpass'):
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': password})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _user(db, client, username, permnames, admin=False):
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
Permission.seed_entries([
|
||||
('endpoints.restart', 'Remote restart', 'endpoints'),
|
||||
])
|
||||
db.session.flush()
|
||||
role = Role(rolename='admin' if admin else username + 'role')
|
||||
existing = Role.query.filter_by(rolename=role.rolename).first()
|
||||
if existing is not None:
|
||||
role = existing
|
||||
else:
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
role.permissions = Permission.query.filter(
|
||||
Permission.name.in_(permnames)).all()
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return _login(client, username)
|
||||
|
||||
|
||||
def _deny_everything(db, assetid):
|
||||
"""A policy that matches this endpoint but permits only an unrelated action,
|
||||
so the action under test is denied by gating."""
|
||||
from plugins.computers.models import (ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy, BusinessPolicyItem)
|
||||
policy = ConfigurationPolicy(name='Locked down', scopejson=None)
|
||||
db.session.add(policy)
|
||||
db.session.flush()
|
||||
db.session.add(ConfigurationPolicyAction(
|
||||
configurationpolicyid=policy.configurationpolicyid,
|
||||
action='mtconnect_status'))
|
||||
business = BusinessPolicy(name='Fleet')
|
||||
db.session.add(business)
|
||||
db.session.flush()
|
||||
db.session.add(BusinessPolicyItem(
|
||||
businesspolicyid=business.businesspolicyid,
|
||||
configurationpolicyid=policy.configurationpolicyid))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _url(assetid):
|
||||
return f'/api/endpoints/{assetid}/commands'
|
||||
|
||||
|
||||
def test_policy_denial_still_applies_without_an_override(client, db):
|
||||
"""The default path must be unchanged: no flag, no bypass."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_deny_everything(db, assetid)
|
||||
headers = _user(db, client, 'plainadmin',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler'})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
assert 'not permitted by policy' in \
|
||||
response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_a_non_admin_cannot_override_a_policy_denial(client, db):
|
||||
"""endpoints.command is enough to queue work, never enough to break glass."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_deny_everything(db, assetid)
|
||||
headers = _user(db, client, 'operator',
|
||||
['endpoints.view', 'endpoints.command'])
|
||||
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler',
|
||||
'policyoverride': True,
|
||||
'overridereason': 'trying it on'})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
assert 'admin' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_an_override_without_a_reason_is_refused(client, db):
|
||||
"""An unexplained break-glass is an unreviewable one."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_deny_everything(db, assetid)
|
||||
headers = _user(db, client, 'reasonless',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler',
|
||||
'policyoverride': True})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
assert 'overridereason' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_an_admin_override_is_accepted_and_audited(client, db):
|
||||
"""The whole point, plus the audit trail that justifies allowing it."""
|
||||
from shopdb.api import AuditLog
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_deny_everything(db, assetid)
|
||||
headers = _user(db, client, 'breakglass',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler',
|
||||
'policyoverride': True,
|
||||
'overridereason': 'line down, need status'})
|
||||
assert response.status_code in (200, 201), response.get_json()
|
||||
|
||||
entry = AuditLog.query.filter_by(action='policyoverridden').first()
|
||||
assert entry is not None, 'break-glass must leave an audit record'
|
||||
|
||||
|
||||
def test_a_refused_override_leaves_no_hold_and_no_audit_row(client, db):
|
||||
"""Validation after the override branch must run before anything is written.
|
||||
|
||||
A targetless service action, and a second command while one is in flight,
|
||||
are both refused. Neither may leave a policyoverridden audit row or a
|
||||
PolicyActionHold behind, or the record claims a break-glass that never ran.
|
||||
"""
|
||||
from shopdb.api import AuditLog
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db, 'refusedbox')
|
||||
_deny_everything(db, assetid)
|
||||
headers = _user(db, client, 'refusedadmin',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
missing_target = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'policyoverride': True,
|
||||
'overridereason': 'no target given'})
|
||||
assert missing_target.status_code == 400, missing_target.get_json()
|
||||
|
||||
db.session.add(EndpointCommand(assetid=assetid, action='collect_now',
|
||||
status=EndpointCommand.STATUS_PENDING))
|
||||
db.session.commit()
|
||||
in_flight = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler',
|
||||
'policyoverride': True,
|
||||
'overridereason': 'queue is busy'})
|
||||
assert in_flight.status_code == 409, in_flight.get_json()
|
||||
|
||||
db.session.expire_all()
|
||||
assert AuditLog.query.filter_by(action='policyoverridden').count() == 0
|
||||
assert PolicyActionHold.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_override_is_checked_after_the_elevated_permission_gate(client, db):
|
||||
"""The override widens WHERE an action may run, never WHICH actions the
|
||||
caller may use.
|
||||
|
||||
A human admin holds every permission via the admin bypass, so this is moot
|
||||
for people and cannot be shown with an admin fixture. It matters for TOKENS:
|
||||
authz suspends the admin bypass for a scoped personal token, so a token
|
||||
scoped to endpoints.command is refused at the elevated gate and never
|
||||
reaches the override branch at all - even though its owner is an admin, and
|
||||
even though it asks for the override explicitly.
|
||||
"""
|
||||
from shopdb.core.models import Permission
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db, 'tokenbox')
|
||||
_deny_everything(db, assetid)
|
||||
Permission.seed_entries([
|
||||
('apitokens.create', 'Mint tokens', 'core'),
|
||||
])
|
||||
db.session.commit()
|
||||
ownerheaders = _user(db, client, 'tokenadmin',
|
||||
['endpoints.view', 'endpoints.command',
|
||||
'apitokens.create'], admin=True)
|
||||
|
||||
minted = client.post('/api/apitokens', headers=ownerheaders,
|
||||
json={'name': 'agent',
|
||||
'scopes': ['endpoints.command']})
|
||||
secret = minted.get_json()['data']['secret']
|
||||
headers = {'Authorization': f'Bearer {secret}'}
|
||||
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'system_restart',
|
||||
'policyoverride': True,
|
||||
'overridereason': 'leaked token tries it on'})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
|
||||
def test_a_hold_suppresses_desired_state_reconciliation(client, db):
|
||||
"""Without this the reconciler undoes the override on the next check-in."""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db, 'desiredbox')
|
||||
|
||||
from plugins.computers.models import (ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy, BusinessPolicyItem)
|
||||
policy = ConfigurationPolicy(name='Keep it running', scopejson=None)
|
||||
db.session.add(policy)
|
||||
db.session.flush()
|
||||
db.session.add(ConfigurationPolicyAction(
|
||||
configurationpolicyid=policy.configurationpolicyid,
|
||||
action='service_start', mode='desired',
|
||||
desiredstate={'running': True}))
|
||||
business = BusinessPolicy(name='Desired fleet')
|
||||
db.session.add(business)
|
||||
db.session.flush()
|
||||
db.session.add(BusinessPolicyItem(
|
||||
businesspolicyid=business.businesspolicyid,
|
||||
configurationpolicyid=policy.configurationpolicyid))
|
||||
db.session.commit()
|
||||
|
||||
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
db.session.add(PolicyActionHold(
|
||||
assetid=assetid, action='service_start',
|
||||
expiresat=datetime.utcnow() + timedelta(minutes=60),
|
||||
reason='maintenance window'))
|
||||
db.session.commit()
|
||||
|
||||
effective = effective_policy_for(assetid)
|
||||
assert 'service_start' not in effective.desired_actions()
|
||||
# A hold suspends drift correction; it does not revoke the permission to
|
||||
# run the action by hand.
|
||||
assert 'service_start' in effective.allowed_actions()
|
||||
|
||||
|
||||
def test_an_expired_hold_stops_suppressing(client, db):
|
||||
"""Policy resumes on its own. Nobody has to remember to clear a hold, and a
|
||||
stalled sweeper cannot silently disable reconciliation forever."""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db, 'expiredbox')
|
||||
|
||||
from plugins.computers.models import (ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy, BusinessPolicyItem)
|
||||
policy = ConfigurationPolicy(name='Still running', scopejson=None)
|
||||
db.session.add(policy)
|
||||
db.session.flush()
|
||||
db.session.add(ConfigurationPolicyAction(
|
||||
configurationpolicyid=policy.configurationpolicyid,
|
||||
action='service_start', mode='desired',
|
||||
desiredstate={'running': True}))
|
||||
business = BusinessPolicy(name='Expiry fleet')
|
||||
db.session.add(business)
|
||||
db.session.flush()
|
||||
db.session.add(BusinessPolicyItem(
|
||||
businesspolicyid=business.businesspolicyid,
|
||||
configurationpolicyid=policy.configurationpolicyid))
|
||||
db.session.add(PolicyActionHold(
|
||||
assetid=assetid, action='service_start',
|
||||
expiresat=datetime.utcnow() - timedelta(minutes=1),
|
||||
reason='window that already closed'))
|
||||
db.session.commit()
|
||||
|
||||
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
|
||||
def test_active_holds_are_visible_to_a_non_admin_viewer(client, db):
|
||||
"""A hold explains drift, so it must not be admin-only reading.
|
||||
|
||||
An operator who can see the machine but not the hold is left debugging
|
||||
suppressed reconciliation as if it were a fault.
|
||||
"""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
|
||||
assetid = _endpoint(db)
|
||||
db.session.add(PolicyActionHold(
|
||||
assetid=assetid, action='service_stop',
|
||||
expiresat=datetime.utcnow() + timedelta(minutes=60),
|
||||
createdbyusername='nightadmin',
|
||||
reason='vendor on site'))
|
||||
db.session.commit()
|
||||
|
||||
headers = _user(db, client, 'viewer', ['endpoints.view'])
|
||||
response = client.get(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
holds = response.get_json()['data']
|
||||
assert len(holds) == 1
|
||||
assert holds[0]['action'] == 'service_stop'
|
||||
assert holds[0]['createdbyusername'] == 'nightadmin'
|
||||
assert holds[0]['reason'] == 'vendor on site'
|
||||
|
||||
|
||||
def test_expired_holds_are_not_reported_as_active(client, db):
|
||||
"""Expiry is evaluated on read, matching the resolver.
|
||||
|
||||
If the route reported expired rows the banner would claim enforcement is
|
||||
paused long after it resumed - worse than showing nothing.
|
||||
"""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
|
||||
assetid = _endpoint(db)
|
||||
db.session.add(PolicyActionHold(
|
||||
assetid=assetid, action='service_stop',
|
||||
expiresat=datetime.utcnow() - timedelta(minutes=1),
|
||||
createdbyusername='nightadmin',
|
||||
reason='window that already closed'))
|
||||
db.session.commit()
|
||||
|
||||
headers = _user(db, client, 'viewer2', ['endpoints.view'])
|
||||
response = client.get(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data'] == []
|
||||
|
||||
|
||||
def _desired_policy(db, assetid, action='service_start'):
|
||||
"""A policy putting one action under desired-state enforcement."""
|
||||
from plugins.computers.models import (ConfigurationPolicy,
|
||||
ConfigurationPolicyAction,
|
||||
BusinessPolicy, BusinessPolicyItem)
|
||||
policy = ConfigurationPolicy(name=f'Desired {action}', scopejson=None)
|
||||
db.session.add(policy)
|
||||
db.session.flush()
|
||||
db.session.add(ConfigurationPolicyAction(
|
||||
configurationpolicyid=policy.configurationpolicyid,
|
||||
action=action, mode='desired', desiredstate={'running': True}))
|
||||
business = BusinessPolicy(name=f'Fleet {action}')
|
||||
db.session.add(business)
|
||||
db.session.flush()
|
||||
db.session.add(BusinessPolicyItem(
|
||||
businesspolicyid=business.businesspolicyid,
|
||||
configurationpolicyid=policy.configurationpolicyid))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_an_admin_can_place_a_hold_directly(client, db):
|
||||
"""The hold must be reachable deliberately, not only by tripping a denial."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'holdadmin',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'action': 'service_start',
|
||||
'reason': 'vendor on site',
|
||||
'minutes': 60})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
holds = response.get_json()['data']
|
||||
assert [h['action'] for h in holds] == ['service_start']
|
||||
|
||||
# The point of a hold: reconciliation stops asking for this action.
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
|
||||
def test_a_non_admin_cannot_place_a_hold(client, db):
|
||||
"""endpoints.command queues work; it does not switch off enforcement."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'holdoperator',
|
||||
['endpoints.view', 'endpoints.command'])
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'action': 'service_start',
|
||||
'reason': 'trying it on'})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
|
||||
def test_a_hold_requires_a_reason(client, db):
|
||||
"""An unexplained hold is an unattributable outage waiting to happen."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'holdadmin2',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'action': 'service_start', 'reason': ' '})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
|
||||
def test_holding_a_non_desired_action_is_refused(client, db):
|
||||
"""A hold on a gate-mode action would suppress nothing.
|
||||
|
||||
Accepting it would report a hold that has no effect, which is worse than
|
||||
refusing outright.
|
||||
"""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_deny_everything(db, assetid)
|
||||
headers = _user(db, client, 'holdadmin3',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'action': 'mtconnect_status',
|
||||
'reason': 'not under enforcement'})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
assert 'nothing to hold' in \
|
||||
response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_an_admin_can_release_a_hold_early(client, db):
|
||||
"""Release resumes reconciliation without waiting for expiry."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'holdadmin4',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
||||
json={'action': 'service_start', 'reason': 'swapping a disk'})
|
||||
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
response = client.delete(
|
||||
f'/api/endpoints/{assetid}/policyholds/service_start', headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data'] == []
|
||||
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
|
||||
def test_releasing_a_hold_keeps_the_audit_row(client, db):
|
||||
"""Release soft-deletes: the hold IS the record of what was suppressed."""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'holdadmin5',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
||||
json={'action': 'service_start', 'reason': 'keep this record'})
|
||||
client.delete(f'/api/endpoints/{assetid}/policyholds/service_start',
|
||||
headers=headers)
|
||||
|
||||
row = PolicyActionHold.query.filter_by(assetid=assetid,
|
||||
action='service_start').first()
|
||||
assert row is not None
|
||||
assert row.isactive is False
|
||||
assert row.reason == 'keep this record'
|
||||
|
||||
|
||||
def test_holdable_actions_lists_desired_actions_with_hold_state(client, db):
|
||||
"""The picker must keep showing an action after it is held.
|
||||
|
||||
Built from desired_actions() it would vanish on being held, making the
|
||||
current state unreadable.
|
||||
"""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'holdadmin6',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/holdableactions',
|
||||
headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data'] == [
|
||||
{'action': 'service_start', 'held': False}]
|
||||
|
||||
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
||||
json={'action': 'service_start', 'reason': 'still listed'})
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/holdableactions',
|
||||
headers=headers)
|
||||
assert response.get_json()['data'] == [
|
||||
{'action': 'service_start', 'held': True}]
|
||||
|
||||
|
||||
def _lock_url(assetid):
|
||||
return f'/api/endpoints/{assetid}/lock'
|
||||
|
||||
|
||||
def test_a_lock_refuses_a_non_admin_command(client, db):
|
||||
"""The whole point: a locked endpoint stops other people acting on it.
|
||||
|
||||
No policy is enabled here, so nothing but the lock can be doing the
|
||||
refusing - otherwise this test would pass for the wrong reason.
|
||||
"""
|
||||
assetid = _endpoint(db)
|
||||
adminheaders = _user(db, client, 'lockadmin',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=adminheaders,
|
||||
json={'reason': 'mid repair'})
|
||||
|
||||
headers = _user(db, client, 'lockoperator',
|
||||
['endpoints.view', 'endpoints.command'])
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler'})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
|
||||
def test_a_lock_refusal_names_the_owner_and_reason(client, db):
|
||||
"""A bare "forbidden" sends an operator hunting through policy for a cause
|
||||
that is not there. The refusal has to say who to ask and why."""
|
||||
assetid = _endpoint(db)
|
||||
adminheaders = _user(db, client, 'lockadmin2',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=adminheaders,
|
||||
json={'reason': 'validated part running'})
|
||||
|
||||
headers = _user(db, client, 'lockoperator2',
|
||||
['endpoints.view', 'endpoints.command'])
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler'})
|
||||
message = response.get_json()['data']['error']['message']
|
||||
assert 'lockadmin2' in message
|
||||
assert 'validated part running' in message
|
||||
|
||||
|
||||
def test_an_admin_acts_through_a_lock_without_an_override(client, db):
|
||||
"""The lock gates WHO may act, not WHICH actions exist, so there is no
|
||||
policy decision for an admin to break past. Requiring break-glass here would
|
||||
mean an admin overriding their own quarantine to do the repair it was set
|
||||
for."""
|
||||
assetid = _endpoint(db)
|
||||
headers = _user(db, client, 'lockadmin3',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=headers,
|
||||
json={'reason': 'mine to work on'})
|
||||
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler'})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_a_lock_cannot_be_broken_by_a_policy_override(client, db):
|
||||
"""The two gates are independent and the lock is the outer one.
|
||||
|
||||
policyoverride exists to push past a POLICY denial. If it also dissolved a
|
||||
lock, any non-admin who learned the flag could act on a quarantined machine,
|
||||
and the lock would be advisory.
|
||||
"""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_deny_everything(db, assetid)
|
||||
adminheaders = _user(db, client, 'lockadmin4',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=adminheaders,
|
||||
json={'reason': 'do not touch'})
|
||||
|
||||
headers = _user(db, client, 'lockoperator4',
|
||||
['endpoints.view', 'endpoints.command'])
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler',
|
||||
'policyoverride': True,
|
||||
'overridereason': 'trying to slip past'})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
assert 'locked' in response.get_json()['data']['error']['message'].lower()
|
||||
|
||||
|
||||
def test_a_non_admin_cannot_place_a_lock(client, db):
|
||||
assetid = _endpoint(db)
|
||||
headers = _user(db, client, 'lockoperator5',
|
||||
['endpoints.view', 'endpoints.command'])
|
||||
|
||||
response = client.post(_lock_url(assetid), headers=headers,
|
||||
json={'reason': 'not my call'})
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
|
||||
def test_a_lock_requires_a_reason(client, db):
|
||||
"""The reason is the message a blocked operator reads, not paperwork."""
|
||||
assetid = _endpoint(db)
|
||||
headers = _user(db, client, 'lockadmin6',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(_lock_url(assetid), headers=headers,
|
||||
json={'reason': ' '})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
|
||||
def test_locking_twice_does_not_stack_a_second_lock(client, db):
|
||||
"""Two active locks on one endpoint is not a state the table should hold,
|
||||
and the caller who locked a locked machine got what they asked for."""
|
||||
assetid = _endpoint(db)
|
||||
headers = _user(db, client, 'lockadmin7',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
first = client.post(_lock_url(assetid), headers=headers,
|
||||
json={'reason': 'first'})
|
||||
second = client.post(_lock_url(assetid), headers=headers,
|
||||
json={'reason': 'second'})
|
||||
assert second.status_code == 200, second.get_json()
|
||||
# The original stands - the second call did not silently replace the reason
|
||||
# the first admin recorded.
|
||||
assert second.get_json()['data']['endpointlockid'] == \
|
||||
first.get_json()['data']['endpointlockid']
|
||||
assert second.get_json()['data']['reason'] == 'first'
|
||||
|
||||
|
||||
def test_releasing_a_lock_restores_non_admin_commands(client, db):
|
||||
assetid = _endpoint(db)
|
||||
adminheaders = _user(db, client, 'lockadmin8',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=adminheaders,
|
||||
json={'reason': 'briefly'})
|
||||
client.delete(_lock_url(assetid), headers=adminheaders)
|
||||
|
||||
headers = _user(db, client, 'lockoperator8',
|
||||
['endpoints.view', 'endpoints.command'])
|
||||
response = client.post(_url(assetid), headers=headers,
|
||||
json={'action': 'service_status',
|
||||
'target': 'spooler'})
|
||||
assert response.status_code == 201, response.get_json()
|
||||
|
||||
|
||||
def test_releasing_a_lock_keeps_the_row_as_history(client, db):
|
||||
"""Released by stamping releasedat, not by deleting. The record of who
|
||||
quarantined a machine is most wanted after the lock is gone and no longer
|
||||
there to explain itself."""
|
||||
from plugins.endpoints.models import EndpointLock
|
||||
|
||||
assetid = _endpoint(db)
|
||||
headers = _user(db, client, 'lockadmin9',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=headers,
|
||||
json={'reason': 'keep this record'})
|
||||
client.delete(_lock_url(assetid), headers=headers)
|
||||
|
||||
row = EndpointLock.query.filter_by(assetid=assetid).first()
|
||||
assert row is not None
|
||||
assert row.reason == 'keep this record'
|
||||
assert row.lockedbyusername == 'lockadmin9'
|
||||
assert row.releasedat is not None
|
||||
assert row.releasedbyusername == 'lockadmin9'
|
||||
|
||||
|
||||
def test_releasing_an_unlocked_endpoint_is_a_404(client, db):
|
||||
"""So a caller can tell "unlocked it" from "there was nothing to unlock"."""
|
||||
assetid = _endpoint(db)
|
||||
headers = _user(db, client, 'lockadmin10',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.delete(_lock_url(assetid), headers=headers)
|
||||
assert response.status_code == 404, response.get_json()
|
||||
|
||||
|
||||
def test_a_non_admin_can_read_the_lock(client, db):
|
||||
"""Read-gated, not admin-gated. An operator who cannot see the lock is left
|
||||
with a bare refusal and files a ticket about a machine that is working."""
|
||||
assetid = _endpoint(db)
|
||||
adminheaders = _user(db, client, 'lockadmin11',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=adminheaders,
|
||||
json={'reason': 'visible to all'})
|
||||
|
||||
headers = _user(db, client, 'lockoperator11', ['endpoints.view'])
|
||||
response = client.get(_lock_url(assetid), headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['reason'] == 'visible to all'
|
||||
|
||||
|
||||
def test_an_unlocked_endpoint_reports_no_lock(client, db):
|
||||
assetid = _endpoint(db)
|
||||
headers = _user(db, client, 'lockoperator12', ['endpoints.view'])
|
||||
|
||||
response = client.get(_lock_url(assetid), headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
# success_response(None) omits data entirely rather than emitting null, so
|
||||
# "no lock" is an absent key. The frontend reads it as response.data?.data
|
||||
# || null, which lands on the same answer either way.
|
||||
assert response.get_json().get('data') is None
|
||||
|
||||
|
||||
def test_a_lock_does_not_suppress_policy_reconciliation(client, db):
|
||||
"""The two axes stay separate. A lock gates callers; it must not reach into
|
||||
the resolver and start suppressing desired-state actions, which is the hold's
|
||||
job and carries an expiry a lock does not have."""
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'lockadmin13',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(_lock_url(assetid), headers=headers,
|
||||
json={'reason': 'locked but still reconciled'})
|
||||
|
||||
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
|
||||
def test_an_admin_can_hold_several_actions_at_once(client, db):
|
||||
"""One dialog submission covers the whole job, not one action per trip.
|
||||
|
||||
An admin about to work on a machine usually needs several actions pinned,
|
||||
and making them repeat the dialog invites stopping halfway.
|
||||
"""
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid, action='service_start')
|
||||
_desired_policy(db, assetid, action='service_stop')
|
||||
headers = _user(db, client, 'multiadmin1',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'actions': ['service_start', 'service_stop'],
|
||||
'reason': 'stripping the enclosure',
|
||||
'minutes': 60})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
held = {h['action'] for h in response.get_json()['data']}
|
||||
assert held == {'service_start', 'service_stop'}
|
||||
|
||||
desired = effective_policy_for(assetid).desired_actions()
|
||||
assert 'service_start' not in desired
|
||||
assert 'service_stop' not in desired
|
||||
|
||||
|
||||
def test_a_multi_action_hold_is_all_or_nothing(client, db):
|
||||
"""One bad name rejects the request whole.
|
||||
|
||||
Partial success is the wrong failure mode on a break-glass control: the
|
||||
admin walks away believing the machine is pinned, and the reconciler undoes
|
||||
the work covered by the holds that never landed.
|
||||
"""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid, action='service_start')
|
||||
headers = _user(db, client, 'multiadmin2',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(
|
||||
f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
# service_start is holdable; mtconnect_status is not under enforcement.
|
||||
json={'actions': ['service_start', 'mtconnect_status'],
|
||||
'reason': 'half of this is invalid'})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
# The valid half must NOT have been written on the way to the refusal.
|
||||
assert PolicyActionHold.query.filter_by(assetid=assetid).count() == 0
|
||||
|
||||
|
||||
def test_a_zero_minute_hold_never_expires(client, db):
|
||||
"""0 means indefinite: it outlives any window a dated hold would have."""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'indefadmin1',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'actions': ['service_start'],
|
||||
'reason': 'awaiting a replacement part',
|
||||
'minutes': 0})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
row = PolicyActionHold.query.filter_by(assetid=assetid,
|
||||
action='service_start').first()
|
||||
assert row.expiresat is None
|
||||
# Reported active and honoured by the resolver despite having no expiry.
|
||||
assert response.get_json()['data'][0]['expiresat'] is None
|
||||
assert 'service_start' not in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
|
||||
def test_an_indefinite_hold_can_still_be_released(client, db):
|
||||
"""The one hold that never self-clears must be clearable by hand.
|
||||
|
||||
Without a null branch in the release query this would be the only kind of
|
||||
hold the release button could not touch - permanently stuck.
|
||||
"""
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'indefadmin2',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
||||
json={'actions': ['service_start'], 'reason': 'indefinite',
|
||||
'minutes': 0})
|
||||
|
||||
response = client.delete(
|
||||
f'/api/endpoints/{assetid}/policyholds/service_start', headers=headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
|
||||
def test_a_negative_hold_window_is_refused(client, db):
|
||||
"""A negative window places an already-expired hold.
|
||||
|
||||
That reads as success in the UI while suppressing nothing - the silent
|
||||
failure the holdable-action check exists to prevent.
|
||||
"""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'indefadmin3',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'actions': ['service_start'],
|
||||
'reason': 'backwards', 'minutes': -30})
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
|
||||
def test_re_holding_an_active_action_does_not_stack_rows(client, db):
|
||||
"""Idempotent, matching the endpoint lock.
|
||||
|
||||
A duplicate row would have to be released once per hidden copy before
|
||||
reconciliation actually resumed.
|
||||
"""
|
||||
from plugins.computers.models import PolicyActionHold
|
||||
from plugins.computers.services.policyresolver import effective_policy_for
|
||||
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'dupeadmin1',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
for _ in range(2):
|
||||
client.post(f'/api/endpoints/{assetid}/policyholds', headers=headers,
|
||||
json={'actions': ['service_start'], 'reason': 'twice'})
|
||||
|
||||
assert PolicyActionHold.query.filter_by(assetid=assetid,
|
||||
action='service_start').count() == 1
|
||||
|
||||
# One release is therefore enough to resume reconciliation.
|
||||
client.delete(f'/api/endpoints/{assetid}/policyholds/service_start',
|
||||
headers=headers)
|
||||
assert 'service_start' in effective_policy_for(assetid).desired_actions()
|
||||
|
||||
|
||||
def test_a_single_action_hold_payload_still_works(client, db):
|
||||
"""The pre-list payload shape stays accepted so older callers keep working."""
|
||||
_enable_policies(db)
|
||||
assetid = _endpoint(db)
|
||||
_desired_policy(db, assetid)
|
||||
headers = _user(db, client, 'legacyadmin1',
|
||||
['endpoints.view', 'endpoints.command'], admin=True)
|
||||
|
||||
response = client.post(f'/api/endpoints/{assetid}/policyholds',
|
||||
headers=headers,
|
||||
json={'action': 'service_start',
|
||||
'reason': 'legacy payload'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert [h['action'] for h in response.get_json()['data']] == \
|
||||
['service_start']
|
||||
200
tests/test_plugins/test_endpoints_script_runs.py
Normal file
200
tests/test_plugins/test_endpoints_script_runs.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""The Run Scripts tab's history feed.
|
||||
|
||||
A successful script produces a green badge and a one-line message; nothing in
|
||||
that tells an operator whether the script did any work. These tests pin the
|
||||
contract the tab relies on: only script runs, newest first, capped, and
|
||||
carrying the agent's structured detail so the exit code and run context are
|
||||
visible without opening the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
|
||||
def _endpoint(db, hostname='scriptbox'):
|
||||
"""An Asset + Computer pair with a reported state, as the routes expect."""
|
||||
from shopdb.api import Asset, AssetType
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.add(EndpointState(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _viewer(db, client, username='scriptviewer'):
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
Permission.seed()
|
||||
db.session.flush()
|
||||
role = Role.query.filter_by(rolename='scriptviewerrole').first()
|
||||
if role is None:
|
||||
role = Role(rolename='scriptviewerrole')
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
role.permissions = Permission.query.filter(
|
||||
Permission.name.in_(['endpoints.view'])).all()
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _script(db, name='test-open-notepad'):
|
||||
from shopdb.core.models import Script
|
||||
|
||||
script = Script(scriptname=name, runcontext='system')
|
||||
db.session.add(script)
|
||||
db.session.commit()
|
||||
return script.scriptid
|
||||
|
||||
|
||||
def _command(db, assetid, target, createdat, action='run_script',
|
||||
status='succeeded', resultjson=None, resultmessage=None):
|
||||
from plugins.endpoints.models import EndpointCommand
|
||||
|
||||
command = EndpointCommand(assetid=assetid, action=action, target=target,
|
||||
status=status, resultmessage=resultmessage,
|
||||
resultjson=resultjson, createdat=createdat)
|
||||
db.session.add(command)
|
||||
db.session.commit()
|
||||
return command.endpointcommandid
|
||||
|
||||
|
||||
def test_only_script_runs_are_returned(app, client, db):
|
||||
"""A restart sitting in the same feed is not a script run.
|
||||
|
||||
The tab asks a narrower question than the activity panel, so filtering has
|
||||
to happen server-side; a mixed feed sliced client-side would quietly show
|
||||
fewer than five script runs whenever other commands interleave.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
|
||||
_command(db, assetid, 'restart', datetime(2026, 1, 1, 11, 0),
|
||||
action='restart')
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
rows = response.get_json()['data']
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [row['action'] for row in rows] == ['run_script']
|
||||
|
||||
|
||||
def test_newest_first_and_capped_at_five(app, client, db):
|
||||
"""Six runs, five rows, most recent at the top."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
for hour in range(6):
|
||||
_command(db, assetid, f'script:{scriptid}',
|
||||
datetime(2026, 1, 1, 10 + hour, 0))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
rows = response.get_json()['data']
|
||||
|
||||
assert len(rows) == 5
|
||||
stamps = [row['createdat'] for row in rows]
|
||||
assert stamps == sorted(stamps, reverse=True)
|
||||
|
||||
|
||||
def test_agent_detail_is_exposed(app, client, db):
|
||||
"""The exit code and run context are the evidence behind a green badge."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
|
||||
resultmessage='Script completed (system context)',
|
||||
resultjson=json.dumps({'kind': 'script', 'exitcode': 0,
|
||||
'runcontext': 'system'}))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
row = response.get_json()['data'][0]
|
||||
|
||||
assert row['resultdetail']['exitcode'] == 0
|
||||
assert row['resultdetail']['runcontext'] == 'system'
|
||||
|
||||
|
||||
def test_the_script_name_is_resolved(app, client, db):
|
||||
"""'script:5' is the wire target, not what the operator picked."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db, name='rebuild-shopfloor-user')
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
row = response.get_json()['data'][0]
|
||||
|
||||
assert row['scriptname'] == 'rebuild-shopfloor-user'
|
||||
|
||||
|
||||
def test_malformed_detail_does_not_break_the_list(app, client, db):
|
||||
"""resultjson is agent-supplied text and can arrive truncated.
|
||||
|
||||
A diagnostics panel failing to parse must degrade to "no detail", not take
|
||||
down the history that surrounds it.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
headers = _viewer(db, client)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0),
|
||||
resultjson='{"kind": "script", "exitcode":')
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns',
|
||||
headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data'][0]['resultdetail'] is None
|
||||
|
||||
|
||||
def test_view_permission_is_required(app, client, db):
|
||||
"""History reveals what has been run on a machine, so it is gated."""
|
||||
from datetime import datetime
|
||||
|
||||
with app.app_context():
|
||||
assetid = _endpoint(db)
|
||||
scriptid = _script(db)
|
||||
_command(db, assetid, f'script:{scriptid}', datetime(2026, 1, 1, 10, 0))
|
||||
|
||||
response = client.get(f'/api/endpoints/{assetid}/scriptruns')
|
||||
|
||||
assert response.status_code in (401, 403)
|
||||
157
tests/test_plugins/test_endpoints_server_certificate.py
Normal file
157
tests/test_plugins/test_endpoints_server_certificate.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Server TLS certificate upload and generate routes.
|
||||
|
||||
The new pair must be parsed, and the key proven to match the certificate,
|
||||
BEFORE the existing files are backed up or replaced: a bad upload must leave
|
||||
nginx's current certificate exactly where it was. cn and san must be plain
|
||||
hostname/IP syntax; anything else (newlines, '=', brackets) is refused.
|
||||
"""
|
||||
|
||||
import io
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from plugins.endpoints.api import routes as endpoint_routes
|
||||
|
||||
UPLOAD_URL = '/api/endpoints/settings/server-cert/upload'
|
||||
GENERATE_URL = '/api/endpoints/settings/server-cert/generate'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cert_dir(tmp_path, monkeypatch):
|
||||
directory = tmp_path / 'certs'
|
||||
directory.mkdir()
|
||||
monkeypatch.setattr(endpoint_routes, '_server_cert_dir', lambda: directory)
|
||||
monkeypatch.setattr(endpoint_routes, '_restart_nginx', lambda: None)
|
||||
return directory
|
||||
|
||||
|
||||
def _pair(cn='old.example.test'):
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
|
||||
now = datetime.now(timezone.utc)
|
||||
certificate = (x509.CertificateBuilder()
|
||||
.subject_name(name).issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1))
|
||||
.not_valid_after(now + timedelta(days=30))
|
||||
.sign(key, hashes.SHA256()))
|
||||
cert_pem = certificate.public_bytes(serialization.Encoding.PEM)
|
||||
key_pem = key.private_bytes(serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption())
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def _seed_existing(cert_dir):
|
||||
cert_pem, key_pem = _pair('existing.example.test')
|
||||
(cert_dir / 'server.crt').write_bytes(cert_pem)
|
||||
(cert_dir / 'server.key').write_bytes(key_pem)
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def _upload(client, headers, cert_bytes, key_bytes):
|
||||
return client.post(UPLOAD_URL, headers=headers,
|
||||
content_type='multipart/form-data',
|
||||
data={'cert': (io.BytesIO(cert_bytes), 'server.crt'),
|
||||
'key': (io.BytesIO(key_bytes), 'server.key')})
|
||||
|
||||
|
||||
def test_a_matching_pair_replaces_and_backs_up(client, db, auth_headers, cert_dir):
|
||||
from shopdb.api import AuditLog
|
||||
|
||||
old_cert, old_key = _seed_existing(cert_dir)
|
||||
new_cert, new_key = _pair('new.example.test')
|
||||
|
||||
response = _upload(client, auth_headers, new_cert, new_key)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert (cert_dir / 'server.crt').read_bytes() == new_cert
|
||||
assert (cert_dir / 'server.key').read_bytes() == new_key
|
||||
backups = sorted(p.name for p in cert_dir.iterdir() if '.backup.' in p.name)
|
||||
assert len(backups) == 2
|
||||
assert not [p for p in cert_dir.iterdir() if p.name.endswith('.tmp')]
|
||||
assert AuditLog.query.filter_by(
|
||||
action='endpoints.server_cert.uploaded').count() == 1
|
||||
|
||||
|
||||
def test_a_mismatched_key_touches_nothing(client, db, auth_headers, cert_dir):
|
||||
old_cert, old_key = _seed_existing(cert_dir)
|
||||
new_cert, _new_key = _pair('new.example.test')
|
||||
_other_cert, other_key = _pair('other.example.test')
|
||||
|
||||
response = _upload(client, auth_headers, new_cert, other_key)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'does not match' in response.get_json()['data']['error']['message']
|
||||
assert (cert_dir / 'server.crt').read_bytes() == old_cert
|
||||
assert (cert_dir / 'server.key').read_bytes() == old_key
|
||||
assert sorted(p.name for p in cert_dir.iterdir()) == ['server.crt', 'server.key']
|
||||
|
||||
|
||||
def test_garbage_certificate_touches_nothing(client, db, auth_headers, cert_dir):
|
||||
old_cert, _old_key = _seed_existing(cert_dir)
|
||||
_cert, key = _pair()
|
||||
garbage = b'-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----\n'
|
||||
|
||||
response = _upload(client, auth_headers, garbage, key)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert (cert_dir / 'server.crt').read_bytes() == old_cert
|
||||
assert len(list(cert_dir.iterdir())) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('cn', [
|
||||
'host\nprompt = yes',
|
||||
'bad host',
|
||||
'a=b',
|
||||
'[alt_names]',
|
||||
'a' * 65,
|
||||
])
|
||||
def test_generate_refuses_unsafe_cn(client, db, auth_headers, cert_dir, cn):
|
||||
_seed_existing(cert_dir)
|
||||
response = client.post(GENERATE_URL, headers=auth_headers,
|
||||
json={'cn': cn, 'days': 30})
|
||||
assert response.status_code == 400
|
||||
assert len(list(cert_dir.iterdir())) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('san', [
|
||||
'DNS:ok.example.test,DNS:bad\nname',
|
||||
'IP:999.1.1.1',
|
||||
'URI:http://x',
|
||||
'DNS:a b',
|
||||
])
|
||||
def test_generate_refuses_unsafe_san(client, db, auth_headers, cert_dir, san):
|
||||
_seed_existing(cert_dir)
|
||||
response = client.post(GENERATE_URL, headers=auth_headers,
|
||||
json={'cn': 'ok.example.test', 'san': san,
|
||||
'days': 30})
|
||||
assert response.status_code == 400
|
||||
assert len(list(cert_dir.iterdir())) == 2
|
||||
|
||||
|
||||
def test_generate_installs_a_valid_pair(client, db, auth_headers, cert_dir):
|
||||
from shopdb.api import AuditLog
|
||||
|
||||
_seed_existing(cert_dir)
|
||||
response = client.post(GENERATE_URL, headers=auth_headers,
|
||||
json={'cn': 'shopdb.example.test',
|
||||
'san': 'DNS:shopdb.example.test,IP:10.0.0.5',
|
||||
'days': 30})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
certificate = x509.load_pem_x509_certificate(
|
||||
(cert_dir / 'server.crt').read_bytes())
|
||||
assert certificate.subject.get_attributes_for_oid(
|
||||
NameOID.COMMON_NAME)[0].value == 'shopdb.example.test'
|
||||
assert endpoint_routes._validate_certificate_pair(
|
||||
(cert_dir / 'server.crt').read_bytes(),
|
||||
(cert_dir / 'server.key').read_bytes()) is None
|
||||
assert AuditLog.query.filter_by(
|
||||
action='endpoints.server_cert.generated').count() == 1
|
||||
162
tests/test_plugins/test_endpoints_settings_declared.py
Normal file
162
tests/test_plugins/test_endpoints_settings_declared.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""The two endpoints switches are declared, seeded, and have a control.
|
||||
|
||||
Both were readable by their enforcement code before they were declared:
|
||||
|
||||
plugin.endpoints.revokeddeniessharedkey agentauth.revoked_denies_shared_key
|
||||
plugin.endpoints.bootstrapquerytoken bootstrap._query_token_allowed
|
||||
|
||||
An undeclared key has no row until somebody writes one, and until it was
|
||||
declared the settings API upserted it with valuetype 'string' - so
|
||||
bool(Setting.get(key, True)) read the text 'false' as True and the switch did
|
||||
nothing. Declaring it fixes both halves: the row is seeded at plugin
|
||||
install/enable, and a PUT lands as a real boolean.
|
||||
|
||||
The UI half is checked by reading the pages. A switch with no control on an
|
||||
air-gapped box can only be changed with SQL, which is not a setting.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.endpoints.api import agentauth, bootstrap
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
from shopdb.core.models import Setting
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
VIEWS = REPO / 'plugins' / 'endpoints' / 'frontend' / 'views'
|
||||
|
||||
REVOKED_KEY = agentauth.REVOKED_DENIES_SHARED_KEY_SETTING
|
||||
QUERY_KEY = bootstrap.QUERY_TOKEN_SETTING
|
||||
|
||||
|
||||
def _declared():
|
||||
return {entry['key']: entry for entry in
|
||||
EndpointsPlugin().get_settings_defaults()}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key,value', [(REVOKED_KEY, 'false'),
|
||||
(QUERY_KEY, 'true')])
|
||||
def test_the_switch_is_declared_with_todays_behaviour(key, value):
|
||||
entry = _declared().get(key)
|
||||
|
||||
assert entry is not None, key + ' is read but never declared'
|
||||
assert entry['valuetype'] == 'boolean'
|
||||
assert entry['category'] == 'plugin.endpoints'
|
||||
# The declared default must be what the code does with no row at all, or
|
||||
# seeding a site silently changes its behaviour.
|
||||
assert entry['value'] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize('key', [REVOKED_KEY, QUERY_KEY])
|
||||
def test_the_description_names_what_turning_it_on_costs(key):
|
||||
"""An operator on the shop floor decides from this text alone."""
|
||||
description = _declared()[key]['description']
|
||||
|
||||
assert len(description) > 120, description
|
||||
if key == REVOKED_KEY:
|
||||
# The revoke + blockreenroll rollback onto an older agent stops working.
|
||||
assert 'rollback' in description
|
||||
assert 'enrolls again' in description
|
||||
else:
|
||||
assert 're-issued' in description or 're-issue' in description
|
||||
|
||||
|
||||
def test_the_declared_defaults_match_what_the_code_reads_with_no_row(db):
|
||||
"""No Setting rows at all: the readers fall back to these same values."""
|
||||
assert Setting.query.filter_by(key=REVOKED_KEY).first() is None
|
||||
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
|
||||
|
||||
assert agentauth.revoked_denies_shared_key() is False
|
||||
assert bootstrap._query_token_allowed() is True
|
||||
|
||||
|
||||
def test_the_upgrade_pass_seeds_them_on_a_site_that_installed_earlier(app, db):
|
||||
"""install/enable fire only on a state transition, so a fleet already
|
||||
running this plugin gets these rows from `flask plugin upgrade-all`, which
|
||||
calls sync_all_plugin_settings -> _seed_plugin_settings.
|
||||
|
||||
This drives _seed_plugin_settings on a manager built for this test rather
|
||||
than the process-wide plugin_manager singleton, and that is deliberate.
|
||||
create_app() re-points that singleton, eight test modules build their own
|
||||
app, and whatever ran last on this xdist worker owns it: the symptoms are
|
||||
"no such table: settings" (seeding against another app's in-memory SQLite)
|
||||
and "'NoneType' object has no attribute 'get_all'" (registry left None).
|
||||
Both land on whichever test follows, so they move with the split - this one
|
||||
passed locally and failed on CI for exactly that reason.
|
||||
|
||||
init_app is not called here either: it re-registers the plugin blueprints,
|
||||
and twice on one app raises "The name 'backups' is already registered".
|
||||
Setting _app and _db is all the seeding routine reads.
|
||||
"""
|
||||
from shopdb.plugins import PluginManager
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
|
||||
|
||||
manager = PluginManager()
|
||||
manager._app = app
|
||||
manager._db = db
|
||||
assert manager._seed_plugin_settings(EndpointsPlugin()) is True
|
||||
|
||||
for key, value in ((REVOKED_KEY, False), (QUERY_KEY, True)):
|
||||
row = Setting.query.filter_by(key=key).one()
|
||||
assert row.valuetype == 'boolean'
|
||||
assert row.category == 'plugin.endpoints'
|
||||
assert row.get_typed_value() is value
|
||||
|
||||
|
||||
def test_a_put_lands_as_a_boolean_and_flips_the_query_token(client, db,
|
||||
auth_headers):
|
||||
"""End to end for bootstrapquerytoken through the API the page uses.
|
||||
|
||||
The declaration is what makes the upsert boolean; as a string row the read
|
||||
below would stay True whatever was saved.
|
||||
"""
|
||||
saved = client.put('/api/settings/' + QUERY_KEY, json={'value': False},
|
||||
headers=auth_headers)
|
||||
|
||||
assert saved.status_code == 200, saved.get_json()
|
||||
body = saved.get_json()['data']
|
||||
assert body['value'] is False
|
||||
assert body['valuetype'] == 'boolean'
|
||||
assert body['category'] == 'plugin.endpoints'
|
||||
assert bootstrap._query_token_allowed() is False
|
||||
|
||||
back = client.put('/api/settings/' + QUERY_KEY, json={'value': True},
|
||||
headers=auth_headers)
|
||||
assert back.get_json()['data']['value'] is True
|
||||
assert bootstrap._query_token_allowed() is True
|
||||
|
||||
|
||||
def test_the_settings_api_lists_the_declared_switch(client, db, auth_headers):
|
||||
"""The agent-key page reads GET /api/settings/<key> to render the box."""
|
||||
client.put('/api/settings/' + QUERY_KEY, json={'value': True},
|
||||
headers=auth_headers)
|
||||
|
||||
resp = client.get('/api/settings/' + QUERY_KEY, headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['value'] is True
|
||||
|
||||
|
||||
def test_the_enrollment_page_carries_the_eviction_checkbox():
|
||||
page = (VIEWS / 'EnrollmentSettings.vue').read_text(encoding='utf-8')
|
||||
|
||||
assert 'toggle-revokeddenies' in page, 'no checkbox for the eviction switch'
|
||||
assert 'onRevokedDeniesToggle' in page
|
||||
# It sits with the two switches it belongs beside.
|
||||
assert 'toggle-sharedkey' in page and 'toggle-autoenroll' in page
|
||||
# The operator has to be told what it breaks before clicking it.
|
||||
assert 'rollback' in page
|
||||
assert 'enrolls again' in page
|
||||
|
||||
|
||||
def test_the_agent_key_page_carries_the_query_token_control():
|
||||
page = (VIEWS / 'AgentKeySettings.vue').read_text(encoding='utf-8')
|
||||
|
||||
assert QUERY_KEY in page, 'the page does not touch the setting'
|
||||
assert 'toggle-querytoken' in page
|
||||
assert 'onQueryTokenToggle' in page
|
||||
# Turning it off breaks commands already handed out, so say so.
|
||||
assert 're-issue' in page
|
||||
@@ -1,141 +0,0 @@
|
||||
"""The shipped shopfloor app monitors ride a read-only Site Policy.
|
||||
|
||||
Migration endpoints0007shippedpolicy offloads the eDNC/UDC/MTConnect monitors
|
||||
from the agent's hardcoded appsettings.json into a product-owned policy bundle:
|
||||
a shipped ConfigurationPolicy "Shopfloor App Monitors" (global scope, one
|
||||
desired-state monitor action per shipped monitor) wrapped in a shipped
|
||||
BusinessPolicy "Shopfloor App Monitors".
|
||||
|
||||
Run the endpoints chain against a fresh engine, alongside the computers policy
|
||||
tables the bundle writes into, and assert the bundle exists, is source='shipped'
|
||||
(read-only), and attaches every shipped monitor as a desired/running:true action.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
|
||||
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
|
||||
|
||||
CONFIG_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
BUSINESS_POLICY_NAME = 'Shopfloor App Monitors'
|
||||
|
||||
|
||||
def _load(path, prefix):
|
||||
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run_bundle_migrations(connection):
|
||||
"""Build the policy tables (computers 0004), the monitor catalog (endpoints
|
||||
0006) and the shipped bundle (endpoints 0007) on one engine, in order.
|
||||
|
||||
The core baseline creates every plugin's tables before per-plugin chains run,
|
||||
so on a real deploy the computers policy tables already exist when endpoints
|
||||
0007 runs. Here we stand up exactly the three migrations the bundle depends
|
||||
on - the earlier endpoints revisions carry FKs to core tables that a bare
|
||||
engine does not have, and are irrelevant to what 0007 seeds.
|
||||
"""
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
_load(_COMPUTERS_VERSIONS / '0004_policies.py',
|
||||
'computers_mig_').upgrade()
|
||||
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
|
||||
'endpoints_mig_0006_').upgrade()
|
||||
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
|
||||
'endpoints_mig_0007_').upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
|
||||
def test_shipped_bundle_seeds_read_only_site_policy():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
config = connection.execute(text(
|
||||
'SELECT configurationpolicyid, source, scopejson '
|
||||
'FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': CONFIG_POLICY_NAME}).first()
|
||||
assert config is not None, 'shipped Configuration Policy was not seeded'
|
||||
config_id, config_source, scopejson = config
|
||||
assert config_source == 'shipped'
|
||||
assert json.loads(scopejson) == {'type': 'true'}
|
||||
|
||||
business = connection.execute(text(
|
||||
'SELECT businesspolicyid, source FROM businesspolicies '
|
||||
'WHERE name = :name'), {'name': BUSINESS_POLICY_NAME}).first()
|
||||
assert business is not None, 'shipped Business Policy was not seeded'
|
||||
business_id, business_source = business
|
||||
assert business_source == 'shipped'
|
||||
|
||||
item = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM businesspolicyitems '
|
||||
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
|
||||
assert item is not None and item[0] == config_id
|
||||
|
||||
|
||||
def test_shipped_bundle_attaches_every_shipped_monitor():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
monitor_ids = {row[0] for row in connection.execute(text(
|
||||
'SELECT endpointmonitorid FROM endpointmonitors '
|
||||
"WHERE source = 'shipped'"))}
|
||||
assert monitor_ids, 'no shipped monitors to attach'
|
||||
|
||||
config_id = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'), {'name': CONFIG_POLICY_NAME}).scalar()
|
||||
|
||||
actions = connection.execute(text(
|
||||
'SELECT action, mode, desiredstate FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': config_id}).all()
|
||||
|
||||
attached_ids = set()
|
||||
for action, mode, desiredstate in actions:
|
||||
assert action.startswith('monitor:')
|
||||
# Gate, not desired: the shipped bundle reports state and exposes
|
||||
# Start/Stop in the UI, but never auto-starts a process. A desired
|
||||
# running state is a deliberate per-monitor opt-in, not shipped.
|
||||
assert mode == 'gate'
|
||||
assert desiredstate is None
|
||||
attached_ids.add(int(action[len('monitor:'):]))
|
||||
|
||||
assert attached_ids == monitor_ids
|
||||
|
||||
|
||||
def test_shipped_bundle_is_idempotent():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
# Re-run only the shipped-bundle migration; it must not duplicate rows.
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
|
||||
'endpoints_mig_rerun_').upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
config_count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': CONFIG_POLICY_NAME}).scalar()
|
||||
business_count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
|
||||
{'name': BUSINESS_POLICY_NAME}).scalar()
|
||||
assert config_count == 1
|
||||
assert business_count == 1
|
||||
353
tests/test_plugins/test_endpoints_siteagent_build.py
Normal file
353
tests/test_plugins/test_endpoints_siteagent_build.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""Site agent package build: server pins, optional shared key, Root import gate.
|
||||
|
||||
The package is the only place a PC gets its first trust anchor and (optionally)
|
||||
the shared fleet key, so these tests read the actual ZIP the route returns
|
||||
rather than the helper in isolation.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from plugins.endpoints.api import agentstore, siteagent
|
||||
from shopdb.core.models.setting import Setting
|
||||
|
||||
PACKAGE_URL = '/api/endpoints/siteagent/package'
|
||||
STATUS_URL = '/api/endpoints/siteagent'
|
||||
SECRET = 'sharedkeyvalue-0123456789abcdef'
|
||||
|
||||
|
||||
def _make_cert(isca):
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,
|
||||
'ca.test' if isca else 'server.test')])
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
builder = (x509.CertificateBuilder()
|
||||
.subject_name(name).issuer_name(name)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=30)))
|
||||
if isca:
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
certificate = builder.sign(key, hashes.SHA256())
|
||||
pem = certificate.public_bytes(serialization.Encoding.PEM)
|
||||
spki = certificate.public_key().public_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
return pem, hashlib.sha256(spki).hexdigest()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate(tmp_path, monkeypatch):
|
||||
"""Empty agent store and no auto-detected server certificate by default."""
|
||||
store = tmp_path / 'siteagent'
|
||||
store.mkdir()
|
||||
monkeypatch.setattr(agentstore, '_siteagent_dir', lambda: str(store))
|
||||
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', ())
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servercert(tmp_path, monkeypatch):
|
||||
"""A self-signed leaf staged as this server's own certificate."""
|
||||
pem, pin = _make_cert(isca=False)
|
||||
path = tmp_path / 'server.crt'
|
||||
path.write_bytes(pem)
|
||||
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
|
||||
return pin
|
||||
|
||||
|
||||
def _build(client, headers, **fields):
|
||||
body = {'baseurl': 'https://shopdb.test', 'cadenceseconds': 900}
|
||||
body.update(fields)
|
||||
return client.post(PACKAGE_URL, json=body, headers=headers)
|
||||
|
||||
|
||||
def _files(response):
|
||||
assert response.status_code == 200, response.get_data(as_text=True)[:400]
|
||||
archive = zipfile.ZipFile(io.BytesIO(response.data))
|
||||
return {name: archive.read(name) for name in archive.namelist()}
|
||||
|
||||
|
||||
def test_installer_writes_current_server_pin(client, db, auth_headers, servercert):
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey=True, bundlecert=True))
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert '[string[]] $ServerPins = @("{0}")'.format(servercert) in script
|
||||
assert 'Set-HklmMultiStringBothViews -SubKey "SOFTWARE\\GE\\ShopDB" -Name "ServerPins"' in script
|
||||
assert '[Microsoft.Win32.RegistryValueKind]::MultiString' in script
|
||||
assert '-ceq $wanted' in script
|
||||
settings = json.loads(files['appsettings.json'])
|
||||
assert settings['serverpins'] == [servercert]
|
||||
|
||||
|
||||
def test_pin_comes_from_server_cert_even_when_not_bundled(
|
||||
client, db, auth_headers, servercert):
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET))
|
||||
assert 'server.crt' not in files
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert]
|
||||
|
||||
|
||||
def test_next_pin_is_shipped_beside_current(client, db, auth_headers, servercert):
|
||||
nextpin = 'ab' * 32
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
nextserverpin=nextpin))
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert '@("{0}", "{1}")'.format(servercert, nextpin) in script
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [servercert, nextpin]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('badpin', ['AB' * 32, 'ab' * 31, 'zz' * 32])
|
||||
def test_next_pin_must_be_64_lowercase_hex(client, db, auth_headers, servercert, badpin):
|
||||
response = _build(client, auth_headers, collectorkey=SECRET, nextserverpin=badpin)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_next_pin_without_a_current_pin_is_refused(client, db, auth_headers):
|
||||
response = _build(client, auth_headers, collectorkey=SECRET,
|
||||
nextserverpin='ab' * 32)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_shared_key_on_keeps_key_provisioning(client, db, auth_headers, servercert):
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey='true'))
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert '[string] $CollectorKey = "{0}"'.format(SECRET) in script
|
||||
assert '-Name "CollectorKey" -Value $CollectorKey' in script
|
||||
assert 'Settings > Endpoints > Enrollment' not in script
|
||||
|
||||
|
||||
def test_shared_key_off_leaves_no_key_anywhere(client, db, auth_headers, servercert):
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey=False))
|
||||
|
||||
for name, content in files.items():
|
||||
assert b'CollectorKey' not in content, name
|
||||
assert SECRET.encode('ascii') not in content, name
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
assert 'Settings > Endpoints > Enrollment' in script
|
||||
assert '$sharedKeyMode = "omitted"' in script
|
||||
assert json.loads(files['appsettings.json'])['apikey'] == ''
|
||||
assert b'Settings > Endpoints > Enrollment' in files['README.txt']
|
||||
|
||||
|
||||
def test_shared_key_default_follows_site_setting(client, db, auth_headers, servercert):
|
||||
# Setting on (default): key required.
|
||||
assert _build(client, auth_headers).status_code == 400
|
||||
|
||||
Setting.set('plugin.endpoints.sharedkeyallowed', False, valuetype='boolean')
|
||||
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
|
||||
assert status['sharedkeyallowed'] is False
|
||||
assert status['serverpin'] == servercert
|
||||
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET))
|
||||
assert b'CollectorKey' not in files['install.ps1']
|
||||
assert SECRET.encode('ascii') not in files['install.ps1']
|
||||
|
||||
|
||||
def test_installer_never_removes_enrolled_credential(client, db, auth_headers, servercert):
|
||||
script = _files(_build(client, auth_headers, collectorkey=SECRET))[
|
||||
'install.ps1'].decode('ascii')
|
||||
assert 'existing enrolled credential found; kept as is.' in script
|
||||
for line in script.splitlines():
|
||||
if 'Remove-Item' in line:
|
||||
assert 'credential' not in line.lower(), line
|
||||
|
||||
|
||||
def _upload(client, headers, pem, filename, **fields):
|
||||
data = {'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
|
||||
'cadenceseconds': '900', 'certfile': (io.BytesIO(pem), filename)}
|
||||
data.update(fields)
|
||||
return client.post(PACKAGE_URL, data=data,
|
||||
content_type='multipart/form-data', headers=headers)
|
||||
|
||||
|
||||
def test_ca_certificate_is_pinned_but_not_imported_into_root(
|
||||
client, db, auth_headers):
|
||||
capem, capin = _make_cert(isca=True)
|
||||
response = _upload(client, auth_headers, capem, 'ca.crt')
|
||||
files = _files(response)
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert 'server.crt' in files
|
||||
assert '$trustCertInRoot = $false' in script
|
||||
assert 'is a CA certificate; not importing it' in script
|
||||
assert 'Install-ShopDBServerCertificate.ps1' in script
|
||||
assert 'older than 1.20' in script
|
||||
# The SPKI pin ignores basicConstraints: the uploaded leaf is pinned.
|
||||
assert '[string[]] $ServerPins = @("{0}")'.format(capin) in script
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
|
||||
assert response.headers.get(siteagent.CA_WARNING_HEADER) == \
|
||||
siteagent.CA_CERT_BUILD_WARNING
|
||||
|
||||
|
||||
def test_uploaded_ca_certificate_ships_both_pins_when_server_cert_differs(
|
||||
client, db, auth_headers, servercert):
|
||||
capem, capin = _make_cert(isca=True)
|
||||
nextpin = 'ef' * 32
|
||||
response = _upload(client, auth_headers, capem, 'ca.crt',
|
||||
nextserverpin=nextpin)
|
||||
files = _files(response)
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert '[string[]] $ServerPins = @("{0}", "{1}", "{2}")'.format(
|
||||
capin, servercert, nextpin) in script
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [
|
||||
capin, servercert, nextpin]
|
||||
assert '$trustCertInRoot = $false' in script
|
||||
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
|
||||
assert siteagent.CA_CERT_BUILD_WARNING in warning
|
||||
assert 'both pins were included' in warning
|
||||
|
||||
|
||||
def test_uploaded_ca_certificate_matching_server_cert_ships_one_pin(
|
||||
client, db, auth_headers, tmp_path, monkeypatch):
|
||||
capem, capin = _make_cert(isca=True)
|
||||
path = tmp_path / 'server.crt'
|
||||
path.write_bytes(capem)
|
||||
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
|
||||
response = _upload(client, auth_headers, capem, 'server.crt')
|
||||
files = _files(response)
|
||||
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [capin]
|
||||
assert 'both pins' not in response.headers.get(siteagent.CA_WARNING_HEADER)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def caservercert(tmp_path, monkeypatch):
|
||||
"""A CA-flagged self-signed cert staged as this server's own certificate,
|
||||
as a plain openssl req -x509 produces."""
|
||||
pem, pin = _make_cert(isca=True)
|
||||
path = tmp_path / 'server.crt'
|
||||
path.write_bytes(pem)
|
||||
monkeypatch.setattr(siteagent, '_CERT_CANDIDATES', (str(path),))
|
||||
return pin
|
||||
|
||||
|
||||
def test_ca_flagged_server_cert_bundled_is_still_pinned(
|
||||
client, db, auth_headers, caservercert):
|
||||
nextpin = 'cd' * 32
|
||||
response = _build(client, auth_headers, collectorkey=SECRET,
|
||||
bundlecert=True, nextserverpin=nextpin)
|
||||
files = _files(response)
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert 'server.crt' in files
|
||||
assert '[string[]] $ServerPins = @("{0}", "{1}")'.format(caservercert, nextpin) in script
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert, nextpin]
|
||||
assert '$trustCertInRoot = $false' in script
|
||||
warning = response.headers.get(siteagent.CA_WARNING_HEADER)
|
||||
assert warning and 'Install-ShopDBServerCertificate.ps1' in warning
|
||||
warning.encode('ascii')
|
||||
|
||||
|
||||
def test_ca_flagged_server_cert_not_bundled_is_still_pinned(
|
||||
client, db, auth_headers, caservercert):
|
||||
response = _build(client, auth_headers, collectorkey=SECRET)
|
||||
files = _files(response)
|
||||
|
||||
assert 'server.crt' not in files
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [caservercert]
|
||||
assert response.headers.get(siteagent.CA_WARNING_HEADER)
|
||||
status = client.get(STATUS_URL, headers=auth_headers).get_json()['data']
|
||||
assert status['serverpin'] == caservercert
|
||||
|
||||
|
||||
def test_leaf_certificate_build_has_no_warning(client, db, auth_headers, servercert):
|
||||
response = _build(client, auth_headers, collectorkey=SECRET, bundlecert=True)
|
||||
_files(response)
|
||||
assert siteagent.CA_WARNING_HEADER not in response.headers
|
||||
|
||||
|
||||
def test_leaf_certificate_is_imported_into_root(client, db, auth_headers):
|
||||
leafpem, leafpin = _make_cert(isca=False)
|
||||
response = client.post(
|
||||
PACKAGE_URL,
|
||||
data={'baseurl': 'https://shopdb.test', 'collectorkey': SECRET,
|
||||
'cadenceseconds': '900',
|
||||
'certfile': (io.BytesIO(leafpem), 'server.crt')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
files = _files(response)
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert '$trustCertInRoot = $true' in script
|
||||
assert 'Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\\LocalMachine\\Root' in script
|
||||
assert json.loads(files['appsettings.json'])['serverpins'] == [leafpin]
|
||||
|
||||
|
||||
def test_installer_restricts_the_registry_key_to_system_and_admins(
|
||||
client, db, auth_headers, servercert):
|
||||
"""The shared key stays in HKLM until every consumer is on a per-PC
|
||||
credential, so what matters meanwhile is who can read it. The key inherits a
|
||||
DACL that grants BUILTIN\\Users read, which is the exposure the per-PC
|
||||
migration exists to close - the installer must not leave it that way."""
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey='true'))
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert 'function Protect-HklmShopdbKey' in script
|
||||
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
|
||||
# SYSTEM and Administrators by SID, so a localised Windows still matches.
|
||||
assert '"S-1-5-18", "S-1-5-32-544"' in script
|
||||
# Inheritance broken WITHOUT copying the inherited entries: copying them is
|
||||
# what silently preserves the Users read.
|
||||
assert 'SetAccessRuleProtection($true, $false)' in script
|
||||
assert 'RemoveAccessRule($rule)' in script
|
||||
|
||||
|
||||
def test_the_key_is_written_before_it_is_locked(client, db, auth_headers, servercert):
|
||||
"""SetAccessControl replaces the DACL rather than merging, so a lock that ran
|
||||
first would be undone by nothing - but a write that ran after a lock the
|
||||
installer no longer holds rights for would fail. Order is the guarantee."""
|
||||
script = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey='true'))['install.ps1'].decode('ascii')
|
||||
|
||||
write = script.index('-Name "CollectorKey" -Value $CollectorKey')
|
||||
lock = script.index('Protect-HklmShopdbKey -SubKey')
|
||||
assert write < lock
|
||||
|
||||
|
||||
def test_the_registry_lock_failing_does_not_fail_the_install(
|
||||
client, db, auth_headers, servercert):
|
||||
"""A bay that cannot take the ACL still needs its agent. It must say plainly
|
||||
that the key is readable there, rather than dying or staying quiet."""
|
||||
script = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey='true'))['install.ps1'].decode('ascii')
|
||||
|
||||
lock = script.index('Protect-HklmShopdbKey -SubKey')
|
||||
tail = script[lock:lock + 600]
|
||||
assert 'catch' in tail
|
||||
assert 'readable by any logged-on user' in tail
|
||||
|
||||
|
||||
def test_the_keyless_package_still_locks_the_key(client, db, auth_headers, servercert):
|
||||
"""ServerPins live in the same key and the registry is authoritative for
|
||||
pinning, so a standard user must not be able to rewrite them either."""
|
||||
files = _files(_build(client, auth_headers, collectorkey=SECRET,
|
||||
includesharedkey=False))
|
||||
script = files['install.ps1'].decode('ascii')
|
||||
|
||||
assert b'CollectorKey' not in files['install.ps1']
|
||||
assert 'Protect-HklmShopdbKey -SubKey "SOFTWARE\\GE\\ShopDB"' in script
|
||||
|
||||
|
||||
def test_install_script_helper_without_pins_or_key():
|
||||
script = siteagent._install_script('https://shopdb.test', '', 900,
|
||||
serverpins=[], includesharedkey=False)
|
||||
assert '[string[]] $ServerPins = @()' in script
|
||||
assert 'CollectorKey' not in script
|
||||
assert 'ServerPins left as is' in script
|
||||
224
tests/test_plugins/test_endpoints_split_monitor_policies.py
Normal file
224
tests/test_plugins/test_endpoints_split_monitor_policies.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""The shipped shopfloor monitors ride one Configuration Policy per monitor.
|
||||
|
||||
0007 seeded a single "Shopfloor App Monitors" bundle wrapped in a shipped Site
|
||||
Policy. 0019 replaced that with one policy per monitor and removed the shipped
|
||||
Site Policy, so sites compose their own.
|
||||
|
||||
Run the endpoints chain against a fresh engine, alongside the computers policy
|
||||
tables the migrations write into, and assert the post-0019 shape: a policy per
|
||||
shipped monitor, each carrying exactly one gate-mode monitor action with no PC
|
||||
type attribution, and no shipped Site Policy left behind.
|
||||
|
||||
The idempotency test is the load-bearing one. Nothing reconciles shipped rows,
|
||||
so insert-if-absent is the only thing protecting a site's edits from an upgrade
|
||||
that re-runs a seeding migration - see POLICY_SOURCE_SHIPPED in
|
||||
plugins/computers/models/policy.py.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
_ENDPOINTS_VERSIONS = _ROOT / 'plugins' / 'endpoints' / 'migrations' / 'versions'
|
||||
_COMPUTERS_VERSIONS = _ROOT / 'plugins' / 'computers' / 'migrations' / 'versions'
|
||||
|
||||
BUNDLE_NAME = 'Shopfloor App Monitors'
|
||||
SPLIT_MIGRATION = '0019_split_shipped_monitor_policies.py'
|
||||
|
||||
_SHIPPED_MONITORS = (
|
||||
('process', 'eDNC'),
|
||||
('process', 'Universal Data Collector'),
|
||||
('process', 'MTConnect'),
|
||||
('service', 'MTConnect'),
|
||||
)
|
||||
|
||||
|
||||
def _policy_name(kind, displayname):
|
||||
return f'Monitor: {displayname} ({kind})'
|
||||
|
||||
|
||||
def _load(path, prefix):
|
||||
spec = importlib.util.spec_from_file_location(prefix + path.stem, str(path))
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _run(connection, *loaded):
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
for module in loaded:
|
||||
module.upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _run_bundle_migrations(connection):
|
||||
"""Build the policy tables, monitor catalog, bundle and split, in order.
|
||||
|
||||
The core baseline creates every plugin's tables before per-plugin chains
|
||||
run, so on a real deploy the computers policy tables already exist. Here we
|
||||
stand up exactly the migrations the bundle depends on - the earlier
|
||||
endpoints revisions carry FKs to core tables a bare engine does not have.
|
||||
"""
|
||||
_run(
|
||||
connection,
|
||||
_load(_COMPUTERS_VERSIONS / '0004_policies.py', 'computers_mig_'),
|
||||
_load(_ENDPOINTS_VERSIONS / '0006_endpoint_monitors.py',
|
||||
'endpoints_mig_0006_'),
|
||||
_load(_ENDPOINTS_VERSIONS / '0007_shipped_monitor_policy.py',
|
||||
'endpoints_mig_0007_'),
|
||||
_load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION, 'endpoints_mig_0019_'),
|
||||
)
|
||||
|
||||
|
||||
def _monitor_id(connection, kind, displayname):
|
||||
return connection.execute(text(
|
||||
'SELECT endpointmonitorid FROM endpointmonitors '
|
||||
'WHERE kind = :kind AND displayname = :displayname'),
|
||||
{'kind': kind, 'displayname': displayname}).scalar()
|
||||
|
||||
|
||||
def test_split_seeds_one_policy_per_shipped_monitor():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
name = _policy_name(kind, displayname)
|
||||
row = connection.execute(text(
|
||||
'SELECT configurationpolicyid, source, scopejson '
|
||||
'FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': name}).first()
|
||||
assert row is not None, f'{name} was not seeded'
|
||||
policyid, source, scopejson = row
|
||||
assert source == 'shipped'
|
||||
# No PC type attribution: the policy applies wherever a site
|
||||
# composes it, and carries no opinion about which types match.
|
||||
assert json.loads(scopejson) == {'type': 'true'}
|
||||
|
||||
actions = connection.execute(text(
|
||||
'SELECT action, mode, desiredstate '
|
||||
'FROM configurationpolicyactions '
|
||||
'WHERE configurationpolicyid = :id'), {'id': policyid}).all()
|
||||
assert len(actions) == 1, f'{name} should carry exactly one action'
|
||||
action, mode, desiredstate = actions[0]
|
||||
# Gate, not desired: a monitor reports state and exposes Start/Stop
|
||||
# but never auto-starts a process. A desired running state is a
|
||||
# deliberate per-monitor opt-in, not something the product ships on.
|
||||
assert mode == 'gate'
|
||||
assert desiredstate is None
|
||||
expected = _monitor_id(connection, kind, displayname)
|
||||
assert action == f'monitor:{expected}'
|
||||
|
||||
|
||||
def test_split_removes_the_shipped_site_policy():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
business = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM businesspolicies WHERE name = :name'),
|
||||
{'name': BUNDLE_NAME}).scalar()
|
||||
assert business == 0, 'the shipped Site Policy must not survive 0019'
|
||||
|
||||
config = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': BUNDLE_NAME}).scalar()
|
||||
assert config == 0, 'the bundled Configuration Policy must be retired'
|
||||
|
||||
items = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM businesspolicyitems')).scalar()
|
||||
assert items == 0
|
||||
|
||||
|
||||
def test_no_shipped_business_policy_remains():
|
||||
"""Sites build their own composition; the product ships none."""
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
shipped = connection.execute(text(
|
||||
"SELECT COUNT(*) FROM businesspolicies WHERE source = 'shipped'"
|
||||
)).scalar()
|
||||
assert shipped == 0
|
||||
|
||||
|
||||
def test_split_is_idempotent_and_preserves_site_edits():
|
||||
"""A re-run must not duplicate rows nor stomp an edit.
|
||||
|
||||
Nothing reconciles shipped rows, so this is the whole protection: the
|
||||
migration returns early when the policy name is already present. If that
|
||||
ever becomes a reconciling seeder, this test is what fails.
|
||||
"""
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
edited = _policy_name('process', 'eDNC')
|
||||
connection.execute(text(
|
||||
'UPDATE configurationpolicies SET description = :description '
|
||||
'WHERE name = :name'),
|
||||
{'description': 'site edited', 'name': edited})
|
||||
connection.commit()
|
||||
|
||||
_run(connection, _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
|
||||
'endpoints_mig_rerun_'))
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': _policy_name(kind, displayname)}).scalar()
|
||||
assert count == 1
|
||||
|
||||
description = connection.execute(text(
|
||||
'SELECT description FROM configurationpolicies WHERE name = :name'),
|
||||
{'name': edited}).scalar()
|
||||
assert description == 'site edited', 'a re-run stomped a site edit'
|
||||
|
||||
|
||||
def test_downgrade_restores_the_bundle():
|
||||
engine = create_engine('sqlite://')
|
||||
with engine.connect() as connection:
|
||||
_run_bundle_migrations(connection)
|
||||
|
||||
split = _load(_ENDPOINTS_VERSIONS / SPLIT_MIGRATION,
|
||||
'endpoints_mig_down_')
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
split.downgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
|
||||
config_id = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM configurationpolicies '
|
||||
'WHERE name = :name'), {'name': BUNDLE_NAME}).scalar()
|
||||
assert config_id is not None, 'downgrade did not restore the bundle'
|
||||
|
||||
business_id = connection.execute(text(
|
||||
'SELECT businesspolicyid FROM businesspolicies WHERE name = :name'),
|
||||
{'name': BUNDLE_NAME}).scalar()
|
||||
assert business_id is not None
|
||||
|
||||
item = connection.execute(text(
|
||||
'SELECT configurationpolicyid FROM businesspolicyitems '
|
||||
'WHERE businesspolicyid = :id'), {'id': business_id}).first()
|
||||
assert item is not None and item[0] == config_id
|
||||
|
||||
for kind, displayname in _SHIPPED_MONITORS:
|
||||
count = connection.execute(text(
|
||||
'SELECT COUNT(*) FROM configurationpolicies '
|
||||
'WHERE name = :name'),
|
||||
{'name': _policy_name(kind, displayname)}).scalar()
|
||||
assert count == 0, 'downgrade left the split policies behind'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user