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:
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user