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