Files
shopdb-flask/tests/test_plugins/test_endpoints_app_conflict.py
cproudlock 066a19052b
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
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.
2026-09-16 08:57:04 -04:00

166 lines
6.2 KiB
Python

"""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)'