Act on the wj architectural review: races, limits, pool and contract gaps
Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
This commit is contained in:
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))
|
||||
@@ -1,7 +1,8 @@
|
||||
"""GET /api/plugins/settings-routes survives a plugin whose hook raises."""
|
||||
"""GET /api/plugins/settings-routes: isolation, and who may read it."""
|
||||
|
||||
|
||||
def test_a_raising_plugin_is_skipped_not_fatal(app, client, db, monkeypatch):
|
||||
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)]
|
||||
@@ -21,8 +22,26 @@ def test_a_raising_plugin_is_skipped_not_fatal(app, client, db, monkeypatch):
|
||||
monkeypatch.setattr(plugins[healthy], 'get_settings_routes', contribute,
|
||||
raising=False)
|
||||
|
||||
response = client.get('/api/plugins/settings-routes')
|
||||
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)
|
||||
|
||||
@@ -26,11 +26,12 @@ PUBLIC_ENDPOINTS = {
|
||||
'setup.needs_admin',
|
||||
'auth.get_current_user',
|
||||
'plugins.list_enabled_plugins',
|
||||
'plugins.get_plugin_settings_routes', # plugin-contributed settings routes
|
||||
# for dynamic UI extension; must answer
|
||||
# unauthenticated pages (kiosks, TV
|
||||
# displays) so they can register plugin
|
||||
# tabs before login
|
||||
# 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
|
||||
|
||||
165
tests/test_plugins/test_endpoints_app_conflict.py
Normal file
165
tests/test_plugins/test_endpoints_app_conflict.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""A concurrent application insert must not sink the whole check-in.
|
||||
|
||||
The unique index on (assetid, appfingerprint) detects a second writer inserting
|
||||
the same application row; it does not resolve one. Before the savepoint went in,
|
||||
the IntegrityError from the insert flush escaped generic_collect, reached the
|
||||
global handler, rolled back the WHOLE check-in - state, events and hardware -
|
||||
and answered the agent with operator-facing 409 form copy.
|
||||
|
||||
The insert now behaves like the delete-count mismatch beside it: warn, keep the
|
||||
telemetry, and let the next check-in (which reports the full list again) settle
|
||||
the inventory.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from plugins.endpoints.models import (
|
||||
EndpointApplication, EndpointEvent, EndpointState,
|
||||
)
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
|
||||
def _fingerprint(name, version, publisher):
|
||||
data = '{}|{}|{}'.format(name or '', version or '', publisher or '')
|
||||
return hashlib.sha256(data.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _computer(db, hostname):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
assettype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if assettype is None:
|
||||
assettype = AssetType(assettype='computer')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, name=hostname,
|
||||
assettypeid=assettype.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _payload(hostname, applications):
|
||||
return {
|
||||
'hostname': hostname,
|
||||
'pendingreboot': True,
|
||||
'applications': applications,
|
||||
'events': [{
|
||||
'eventid': 4625,
|
||||
'eventrecordid': 501,
|
||||
'eventchannel': 'Security',
|
||||
'eventsource': 'Microsoft-Windows-Security-Auditing',
|
||||
'eventmessage': 'Failed logon',
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_insert_conflict_keeps_the_checkin(db, monkeypatch):
|
||||
"""A REAL IntegrityError on the insert flush leaves the check-in intact.
|
||||
|
||||
The first (bulk) insert adds a genuine duplicate row, so the flush raises on
|
||||
the unique index exactly as a concurrent check-in would make it. The
|
||||
reconcile then re-reads and re-inserts, and state plus events still commit.
|
||||
"""
|
||||
assetid = _computer(db, 'APPRACE01')
|
||||
plugin = EndpointsPlugin()
|
||||
|
||||
real_insert = EndpointsPlugin._insert_applications
|
||||
calls = []
|
||||
|
||||
def conflicting_insert(assetid_arg, incoming, fingerprints):
|
||||
rows = real_insert(assetid_arg, incoming, fingerprints)
|
||||
calls.append(list(fingerprints))
|
||||
if len(calls) == 1:
|
||||
# The conflicting row a second writer would have committed.
|
||||
first = rows[0]
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=first.assetid,
|
||||
displayname=first.displayname,
|
||||
displayversion=first.displayversion,
|
||||
publisher=first.publisher,
|
||||
appfingerprint=first.appfingerprint,
|
||||
))
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(EndpointsPlugin, '_insert_applications',
|
||||
staticmethod(conflicting_insert))
|
||||
|
||||
applications = [
|
||||
{'displayname': 'Alpha', 'displayversion': '1.0', 'publisher': 'ACME',
|
||||
'fingerprint': _fingerprint('Alpha', '1.0', 'ACME')},
|
||||
{'displayname': 'Beta', 'displayversion': '2.0', 'publisher': 'ACME',
|
||||
'fingerprint': _fingerprint('Beta', '2.0', 'ACME')},
|
||||
]
|
||||
|
||||
result = plugin.apply_collector_payload(_payload('APPRACE01', applications))
|
||||
db.session.commit()
|
||||
|
||||
# The collision was detected and reported rather than raised.
|
||||
assert len(calls) > 1, 'the bulk insert should have collided and reconciled'
|
||||
assert any('application insert conflict' in w for w in result['warnings'])
|
||||
|
||||
# The telemetry the check-in carried is still there.
|
||||
state = EndpointState.query.filter_by(assetid=assetid).first()
|
||||
assert state is not None
|
||||
assert state.pendingreboot is True
|
||||
assert state.lastreportedat is not None
|
||||
assert EndpointEvent.query.filter_by(assetid=assetid).count() == 1
|
||||
|
||||
# And the inventory reconciled rather than being abandoned.
|
||||
stored = {row.appfingerprint for row
|
||||
in EndpointApplication.query.filter_by(assetid=assetid).all()}
|
||||
assert stored == {app['fingerprint'] for app in applications}
|
||||
|
||||
|
||||
def test_reconcile_skips_fingerprints_that_now_exist(db):
|
||||
"""Reconcile inserts only what is missing and counts the rest as skipped."""
|
||||
assetid = _computer(db, 'APPRACE02')
|
||||
plugin = EndpointsPlugin()
|
||||
|
||||
taken = _fingerprint('Alpha', '1.0', 'ACME')
|
||||
missing = _fingerprint('Beta', '2.0', 'ACME')
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid, displayname='Alpha', displayversion='1.0',
|
||||
publisher='ACME', appfingerprint=taken))
|
||||
db.session.commit()
|
||||
|
||||
incoming = {
|
||||
taken: {'displayname': 'Alpha', 'displayversion': '1.0',
|
||||
'publisher': 'ACME'},
|
||||
missing: {'displayname': 'Beta', 'displayversion': '2.0',
|
||||
'publisher': 'ACME'},
|
||||
}
|
||||
warnings = []
|
||||
stored = plugin._reconcile_applications(assetid, incoming,
|
||||
[taken, missing], warnings)
|
||||
db.session.commit()
|
||||
|
||||
assert stored == 1
|
||||
assert warnings == ['application insert conflict: stored 1, skipped 1 '
|
||||
'of 2 new (concurrent update?)']
|
||||
rows = EndpointApplication.query.filter_by(assetid=assetid).all()
|
||||
assert len(rows) == 2
|
||||
assert {r.appfingerprint for r in rows} == {taken, missing}
|
||||
|
||||
|
||||
def test_the_unique_index_is_really_there(db):
|
||||
"""The whole fix rests on the constraint raising. Prove it does."""
|
||||
assetid = _computer(db, 'APPRACE03')
|
||||
fingerprint = _fingerprint('Alpha', '1.0', 'ACME')
|
||||
for _ in range(2):
|
||||
db.session.add(EndpointApplication(
|
||||
assetid=assetid, displayname='Alpha', displayversion='1.0',
|
||||
publisher='ACME', appfingerprint=fingerprint))
|
||||
raised = False
|
||||
try:
|
||||
db.session.flush()
|
||||
except IntegrityError:
|
||||
raised = True
|
||||
db.session.rollback()
|
||||
assert raised, 'endpointapplications has no unique (assetid, appfingerprint)'
|
||||
@@ -688,7 +688,8 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
body = response.get_json()
|
||||
data = body['data']
|
||||
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True}
|
||||
assert data['settings'] == {'sharedkeyallowed': True, 'autoenroll': True,
|
||||
'maxpendingenrollments': 500}
|
||||
assert data['counts'] == {'enrolled': 1, 'notenrolled': 1,
|
||||
'pendingcomputers': 1, 'pending': 1}
|
||||
states = {row['hostname']: row['state'] for row in data['computers']}
|
||||
@@ -709,7 +710,8 @@ def test_enrollment_listing_and_settings(client, db, app, keyheaders,
|
||||
json={'sharedkeyallowed': False}, headers=auth_headers)
|
||||
assert put.status_code == 200
|
||||
assert put.get_json()['data'] == {'sharedkeyallowed': False,
|
||||
'autoenroll': True}
|
||||
'autoenroll': True,
|
||||
'maxpendingenrollments': 500}
|
||||
bad = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'autoenroll': 'yes'}, headers=auth_headers)
|
||||
assert bad.status_code == 400
|
||||
|
||||
220
tests/test_plugins/test_endpoints_enroll_limits.py
Normal file
220
tests/test_plugins/test_endpoints_enroll_limits.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""The two bounds on POST /api/endpoints/enroll.
|
||||
|
||||
The route takes no auth on purpose - a PC installed without the shared key must
|
||||
still be able to reach an admin - and it writes a row for every new
|
||||
(hostname, serialnumber) pair. That is an unauthenticated caller writing to the
|
||||
database in a loop, and it is also a denial of the enrollment workflow: junk
|
||||
pending rows bury the real requests an admin has to approve.
|
||||
|
||||
Two bounds, and they answer different questions. The per-IP fixed-window limiter
|
||||
bounds how FAST; the settings-backed cap bounds how MANY rows exist. Neither may
|
||||
touch a PC already in the workflow, so the cap refuses only a hostname and
|
||||
serial never seen before and an existing pending or approved row still answers
|
||||
exactly as it did.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Setting
|
||||
|
||||
from plugins.endpoints.api.enrollment import PENDING_CAP_SETTING
|
||||
from plugins.endpoints.models import EndpointEnrollRequest
|
||||
|
||||
|
||||
ENROLL_URL = '/api/endpoints/enroll'
|
||||
NONCE = 'n' * 40
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def limits(app):
|
||||
"""Both bounds on, at their shipped defaults, restored afterwards.
|
||||
|
||||
The limiter counts in the cache extension, which is process-wide and
|
||||
outlives a test. Any other test in the same worker that posts to /enroll
|
||||
spends budget in the same window and IP bucket, so a count-exactly test has
|
||||
to start from a clean counter - and leave one behind, since it deliberately
|
||||
fills a bucket. Which tests share a worker depends on the xdist
|
||||
distribution, so without this the failure is intermittent and looks
|
||||
unrelated to whatever changed.
|
||||
"""
|
||||
from shopdb.api import cache
|
||||
|
||||
previous = {key: app.config.get(key) for key in (
|
||||
'ENDPOINTS_ENROLL_RATELIMIT_ENABLED',
|
||||
'ENDPOINTS_ENROLL_RATELIMIT_MAX',
|
||||
'ENDPOINTS_ENROLL_RATELIMIT_WINDOW_SECONDS')}
|
||||
app.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = True
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
yield app
|
||||
with app.app_context():
|
||||
cache.clear()
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
app.config.pop(key, None)
|
||||
else:
|
||||
app.config[key] = value
|
||||
|
||||
|
||||
def _computer(db, hostname, serialnumber):
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
atype = AssetType.query.filter_by(assettype='computer').first()
|
||||
if atype is None:
|
||||
atype = AssetType(assettype='computer')
|
||||
db.session.add(atype)
|
||||
db.session.flush()
|
||||
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
|
||||
serialnumber=serialnumber, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
db.session.add(Computer(assetid=asset.assetid, hostname=hostname))
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def _body(hostname, serialnumber=None, nonce=NONCE):
|
||||
return {'hostname': hostname,
|
||||
'serialnumber': serialnumber or (hostname + '-SER'),
|
||||
'enrollnonce': nonce, 'agentversion': '1.20.0'}
|
||||
|
||||
|
||||
def _cap(db, value):
|
||||
Setting.set(PENDING_CAP_SETTING, value, valuetype='integer',
|
||||
category='plugin.endpoints')
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _pending(db, hostname, serialnumber=None, nonce=NONCE,
|
||||
status=EndpointEnrollRequest.STATUS_PENDING):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
row = EndpointEnrollRequest(
|
||||
hostname=hostname, serialnumber=serialnumber or (hostname + '-SER'),
|
||||
enrollnonce=nonce, status=status, requestedat=now)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _code(response):
|
||||
return response.get_json()['data']['error']['code']
|
||||
|
||||
|
||||
# -- rate limit --------------------------------------------------------------
|
||||
|
||||
def test_the_limit_triggers_and_answers_the_standard_shape(client, db, limits):
|
||||
"""Over budget is a 429 RATE_LIMITED, the same shape as every other
|
||||
limiter in the product."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_MAX'] = 3
|
||||
# An hour, so the fixed window cannot roll over mid-test and reset the
|
||||
# count. The test is about the budget, not about the clock.
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_WINDOW_SECONDS'] = 3600
|
||||
|
||||
codes = []
|
||||
for index in range(5):
|
||||
response = client.post(ENROLL_URL, json=_body('BAY-%02d' % index))
|
||||
codes.append(response.status_code)
|
||||
|
||||
assert codes[:3] == [202, 202, 202], codes
|
||||
assert codes[3:] == [429, 429], codes
|
||||
|
||||
refused = client.post(ENROLL_URL, json=_body('BAY-99'))
|
||||
assert refused.status_code == 429
|
||||
assert _code(refused) == 'RATE_LIMITED'
|
||||
|
||||
|
||||
def test_the_limit_is_off_when_the_config_says_so(client, db, limits):
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_MAX'] = 1
|
||||
|
||||
for index in range(4):
|
||||
response = client.post(ENROLL_URL, json=_body('OFF-%02d' % index))
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
|
||||
|
||||
# -- pending cap -------------------------------------------------------------
|
||||
|
||||
def test_the_cap_refuses_a_new_row(client, db, limits):
|
||||
"""At the cap, a hostname and serial nobody has seen is told to try later,
|
||||
and no row is written for it."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
_cap(db, 2)
|
||||
_pending(db, 'OLD-01')
|
||||
_pending(db, 'OLD-02')
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body('NEW-01'))
|
||||
|
||||
assert response.status_code == 503, response.get_data(as_text=True)
|
||||
assert _code(response) == 'ENROLL_PENDING_FULL'
|
||||
assert EndpointEnrollRequest.query.filter_by(hostname='NEW-01').count() == 0
|
||||
assert EndpointEnrollRequest.query.count() == 2
|
||||
|
||||
|
||||
def test_the_cap_still_serves_an_existing_pending_row(client, db, limits):
|
||||
"""A PC already in the queue keeps asking and keeps being answered - the cap
|
||||
must not lock the fleet out of the workflow it is protecting."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
_cap(db, 1)
|
||||
row = _pending(db, 'OLD-01')
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body('OLD-01'))
|
||||
|
||||
assert response.status_code == 202, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['status'] == 'pending'
|
||||
db.session.refresh(row)
|
||||
assert row.lastseenat is not None
|
||||
|
||||
|
||||
def test_the_cap_still_issues_to_an_approved_row(client, db, limits):
|
||||
"""An approved request hands out its credential at the cap, because that
|
||||
row is the admin's decision and refusing it would strand the PC."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
assetid = _computer(db, 'BAY-07', 'BAY-07-SER')
|
||||
_cap(db, 1)
|
||||
_pending(db, 'FILLER-01')
|
||||
approved = _pending(db, 'BAY-07',
|
||||
status=EndpointEnrollRequest.STATUS_APPROVED)
|
||||
approved.matchedassetid = assetid
|
||||
db.session.commit()
|
||||
|
||||
response = client.post(ENROLL_URL, json=_body('BAY-07'))
|
||||
|
||||
assert response.status_code == 201, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['credential'].startswith('sdbe_')
|
||||
|
||||
|
||||
def test_the_cap_setting_is_honoured(client, db, limits):
|
||||
"""Raise the setting and the same request that was refused goes through;
|
||||
0 removes the cap entirely."""
|
||||
limits.config['ENDPOINTS_ENROLL_RATELIMIT_ENABLED'] = False
|
||||
_cap(db, 1)
|
||||
_pending(db, 'OLD-01')
|
||||
|
||||
refused = client.post(ENROLL_URL, json=_body('NEW-01'))
|
||||
assert refused.status_code == 503
|
||||
|
||||
_cap(db, 5)
|
||||
allowed = client.post(ENROLL_URL, json=_body('NEW-01'))
|
||||
assert allowed.status_code == 202, allowed.get_data(as_text=True)
|
||||
|
||||
_cap(db, 0)
|
||||
uncapped = client.post(ENROLL_URL, json=_body('NEW-02'))
|
||||
assert uncapped.status_code == 202, uncapped.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_the_cap_is_editable_through_the_enrollment_settings_route(
|
||||
client, db, auth_headers):
|
||||
"""A setting nobody can change is a constant with extra steps."""
|
||||
response = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'maxpendingenrollments': 42},
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_data(as_text=True)
|
||||
assert response.get_json()['data']['maxpendingenrollments'] == 42
|
||||
assert int(Setting.get(PENDING_CAP_SETTING)) == 42
|
||||
|
||||
bad = client.put('/api/endpoints/enrollment/settings',
|
||||
json={'maxpendingenrollments': -1}, headers=auth_headers)
|
||||
assert bad.status_code == 400
|
||||
@@ -82,7 +82,7 @@ def test_fleet_alert_respects_future_starttime(client, db, app):
|
||||
db.session.commit()
|
||||
|
||||
alerts = _poll(client, app, 'TEST-PC-01')
|
||||
assert [a['notificationId'] for a in alerts] == [note.notificationid]
|
||||
assert [a['notificationid'] for a in alerts] == [note.notificationid]
|
||||
assert alerts[0]['title'] == 'Scheduled maintenance at 3:30 PM'
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_fleet_alert_with_no_starttime_appears_immediately(client, db, app):
|
||||
note = _fleet_alert(db, 'Immediate alert - urgent')
|
||||
|
||||
alerts = _poll(client, app, 'TEST-PC-02')
|
||||
assert [a['notificationId'] for a in alerts] == [note.notificationid]
|
||||
assert [a['notificationid'] for a in alerts] == [note.notificationid]
|
||||
assert alerts[0]['title'] == 'Immediate alert - urgent'
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ def test_fleet_alert_past_starttime_appears(client, db, app):
|
||||
starttime=_now() - timedelta(hours=1))
|
||||
|
||||
alerts = _poll(client, app, 'TEST-PC-03')
|
||||
assert [a['notificationId'] for a in alerts] == [note.notificationid]
|
||||
assert [a['notificationid'] for a in alerts] == [note.notificationid]
|
||||
|
||||
|
||||
def test_fleet_alert_expired_does_not_appear(client, db, app):
|
||||
|
||||
@@ -107,18 +107,42 @@ def test_agent_polls_its_active_fleet_alerts(client, db, app):
|
||||
|
||||
assert resp.status_code == 200, resp.get_data(as_text=True)
|
||||
alerts = resp.get_json()['data']
|
||||
assert [a['notificationId'] for a in alerts] == [alert.notificationid]
|
||||
assert [a['notificationid'] for a in alerts] == [alert.notificationid]
|
||||
assert alerts[0]['message'] == 'Line 3 is down'
|
||||
assert alerts[0]['timeout'] == 60
|
||||
# The tray renders the type colour, so it has to come down with the alert
|
||||
# rather than being guessed client side.
|
||||
assert alerts[0]['typeColor'] == '#d93f3f'
|
||||
assert alerts[0]['typecolor'] == '#d93f3f'
|
||||
|
||||
|
||||
def test_the_feed_still_carries_the_legacy_camelcase_keys(client, db, app):
|
||||
"""TRANSITION for trays older than 1.7.0.
|
||||
|
||||
A deployed 1.6.x tray fetches this feed itself and deserializes
|
||||
case-sensitively, so serving only the lowercase keys would make every alert
|
||||
disappear on those PCs with no error anywhere. Both spellings go down, with
|
||||
identical values, until the fleet is upgraded - then this test and the two
|
||||
camelCase keys go together.
|
||||
"""
|
||||
_computer(db, 'BAY-01')
|
||||
alert = _fleet_alert(db, message='Line 3 is down')
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01',
|
||||
headers=_collector_headers(app))
|
||||
|
||||
row = resp.get_json()['data'][0]
|
||||
assert row['notificationId'] == row['notificationid'] == alert.notificationid
|
||||
assert row['typeColor'] == row['typecolor'] == '#d93f3f'
|
||||
|
||||
|
||||
def test_poll_requires_the_collector_key(client, db, app):
|
||||
"""The alert feed is agent-facing, not anonymous."""
|
||||
_computer(db, 'BAY-01')
|
||||
_fleet_alert(db)
|
||||
# Configure the key here rather than inheriting it from whichever test ran
|
||||
# first: under xdist this file is split across workers, and an unconfigured
|
||||
# key answers 500 instead of the 401 the test is about.
|
||||
app.config['COLLECTOR_API_KEY'] = COLLECTOR_KEY
|
||||
|
||||
resp = client.get(f'{ALERTS_URL}?hostname=BAY-01')
|
||||
|
||||
@@ -251,7 +275,7 @@ def test_one_bay_cannot_acknowledge_for_another(client, db, app):
|
||||
# And BAY-02's alert is untouched by the attempt.
|
||||
feed = client.get(f'{ALERTS_URL}?hostname=BAY-02',
|
||||
headers=_collector_headers(app))
|
||||
assert [a['notificationId'] for a in feed.get_json()['data']] == [
|
||||
assert [a['notificationid'] for a in feed.get_json()['data']] == [
|
||||
alert.notificationid]
|
||||
|
||||
|
||||
@@ -267,7 +291,7 @@ def test_acknowledging_one_bay_leaves_the_others_alerted(client, db, app):
|
||||
headers=headers)
|
||||
|
||||
still = client.get(f'{ALERTS_URL}?hostname=BAY-02', headers=headers)
|
||||
assert [a['notificationId'] for a in still.get_json()['data']] == [
|
||||
assert [a['notificationid'] for a in still.get_json()['data']] == [
|
||||
alert.notificationid]
|
||||
|
||||
|
||||
@@ -323,7 +347,7 @@ def _ids(client, app, hostname):
|
||||
resp = client.get(f'{ALERTS_URL}?hostname={hostname}',
|
||||
headers=_collector_headers(app))
|
||||
assert resp.status_code == 200, resp.get_data(as_text=True)
|
||||
return [a['notificationId'] for a in resp.get_json()['data']]
|
||||
return [a['notificationid'] for a in resp.get_json()['data']]
|
||||
|
||||
|
||||
def test_no_scope_still_means_the_whole_fleet(client, db, app):
|
||||
|
||||
Reference in New Issue
Block a user