Files
shopdb-flask/tests/test_plugins/test_usb_reports.py
cproudlock 35d4fcd41c Report whether a returned drive was wiped and scanned
The USB audit answered who had which drive and when, but not whether it came
back wiped or virus-scanned. That is the question the record is kept to answer:
removable-media custody is CMMC evidence, and custody without the state of the
media on return is half the story. Both stores already held the values - the
external checkinoutlog in sanitized and scanned_viruses, the app's own
usbcheckouts in waswiped and scannedviruses - and neither mode selected them.

Both now report them, in the JSON, the CSV and the column list, so the two
directory modes still answer in one voice.

Two things the rendering has to get right:

- Only a return carries them. The wipe and the scan happen when the drive comes
  back, so a check-out row shows blanks rather than implying a fact about an
  event that had not happened yet.
- Unknown is not "no". Both stores keep these nullable deliberately: a null
  means nobody wrote it down, which is a different claim from a drive that came
  back unwiped. Rendering an unrecorded return as "No" would put an assertion
  in an audit record that no one ever made, so unknown stays blank and reads as
  the gap it is. A test holds that, because "no" is the tempting default.

auditflag lives in selfhosted.py rather than routes.py: routes already imports
selfhosted, so the helper had to sit on that side or the import would be
circular.
2026-09-16 15:41:13 -04:00

203 lines
8.4 KiB
Python

"""USB check-in / check-out audit report.
CMMC removable-media tracking is an audit obligation, and the question an audit
asks is "who had this drive, when" - which a total cannot answer. So this
reports EVENTS over a window, with the window named rather than expressed as a
day count nobody should have to work out.
"""
from datetime import datetime, timedelta, timezone
import pytest
from shopdb.core.models import Setting
def _now():
return datetime.now(timezone.utc).replace(tzinfo=None)
@pytest.fixture
def usbdata(db):
"""Self-hosted USB with a loan returned today and one from long ago."""
from plugins.usb.models import USBDevice, USBCheckout
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
recent = USBDevice(serialnumber='USB-1', label='Blue Kingston')
old = USBDevice(serialnumber='USB-2', label='Red SanDisk')
db.session.add_all([recent, old])
db.session.flush()
# Taken and returned today: two events.
db.session.add(USBCheckout(usbdeviceid=recent.usbdeviceid, sso='270015376',
checkoutname='Dana Reyes',
checkouttime=_now() - timedelta(hours=3),
checkintime=_now() - timedelta(hours=1)))
# Taken 200 days ago and never returned: one event, outside most windows.
db.session.add(USBCheckout(usbdeviceid=old.usbdeviceid, sso='270099999',
checkoutname='Sam Okafor',
checkouttime=_now() - timedelta(days=200)))
db.session.commit()
return db
def _report(client, headers, **params):
query = '&'.join(f'{k}={v}' for k, v in params.items())
response = client.get(f'/api/usb/reports/audit?{query}', headers=headers)
assert response.status_code == 200, response.get_json()
return response.get_json()['data']
def test_a_loan_produces_both_a_take_and_a_return(client, usbdata, auth_headers):
"""One stored row holds both ends of a loan.
Reporting it once would make a returned drive look identical to one still
out, which is the opposite of what the log is for.
"""
data = _report(client, auth_headers, period='month')
actions = sorted(row['action'] for row in data['rows'])
assert actions == ['check-in', 'check-out']
assert data['summary']['checkouts'] == 1
assert data['summary']['checkins'] == 1
def test_the_window_actually_bounds_the_rows(client, usbdata, auth_headers):
month = _report(client, auth_headers, period='month')
year = _report(client, auth_headers, period='year')
assert {row['device_id'] for row in month['rows']} == {'USB-1'}
assert {row['device_id'] for row in year['rows']} == {'USB-1', 'USB-2'}
def test_all_time_keeps_everything(client, usbdata, auth_headers):
data = _report(client, auth_headers, period='all')
assert data['days'] == 0
assert data['summary']['events'] == 3
def test_today_is_a_window_too(client, usbdata, auth_headers):
"""A shift handover asks what happened today, not this month."""
data = _report(client, auth_headers, period='day')
assert data['days'] == 1
assert data['summary']['events'] == 2
@pytest.mark.parametrize('period,days', [('day', 1), ('month', 30),
('sixmonths', 182), ('year', 365),
('all', 0)])
def test_every_named_window_maps_to_a_day_count(client, usbdata, auth_headers,
period, days):
assert _report(client, auth_headers, period=period)['days'] == days
def test_an_unknown_period_falls_back_rather_than_failing(client, usbdata,
auth_headers):
"""A bad query string should not 500 a report somebody is mid-audit on."""
assert _report(client, auth_headers, period='fortnight')['days'] == 30
def test_rows_are_newest_first(client, usbdata, auth_headers):
rows = _report(client, auth_headers, period='all')['rows']
stamps = [row['timestamp'] for row in rows]
assert stamps == sorted(stamps, reverse=True)
def test_it_says_who_and_which_drive(client, usbdata, auth_headers):
rows = _report(client, auth_headers, period='month')['rows']
row = rows[0]
assert row['name'] == 'Dana Reyes'
assert row['badge'] == '270015376'
assert row['device_id'] == 'USB-1'
assert row['device_desc'] == 'Blue Kingston'
def test_it_exports_as_csv(client, usbdata, auth_headers):
response = client.get('/api/usb/reports/audit?period=all&format=csv',
headers=auth_headers)
assert response.status_code == 200
assert response.mimetype == 'text/csv'
body = response.get_data(as_text=True)
assert body.splitlines()[0].startswith('timestamp,action,device_id')
assert 'Dana Reyes' in body
def test_reading_the_audit_needs_permission(client, usbdata, member_headers):
"""A role-less member cannot read who had which drive."""
response = client.get('/api/usb/reports/audit?period=all',
headers=member_headers)
assert response.status_code == 403
def test_the_report_is_registered_so_it_appears_in_the_catalogue(client, usbdata,
auth_headers):
from plugins.usb.plugin import USBPlugin
report = USBPlugin().get_reports()[0]
assert report['id'] == 'usb-audit'
# The endpoint is what the reports page fetches; a wrong one renders blank.
assert report['endpoint'] == '/api/usb/reports/audit'
# -- sanitized / virus-scan state in the audit ---------------------------------
# CMMC evidence: the report answered who had which drive and when, but not
# whether it was wiped or scanned on the way back - which is the question the
# record exists to answer. Both stores held the values; neither reported them.
def test_audit_reports_wipe_and_scan_state_on_returns(db, client, auth_headers):
from plugins.usb.models import USBDevice, USBCheckout
from shopdb.core.models import Setting
from datetime import datetime, timedelta
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
device = USBDevice(serialnumber='USB-9', label='Grey SanDisk')
db.session.add(device)
db.session.flush()
now = datetime.utcnow()
db.session.add(USBCheckout(
usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana',
checkouttime=now - timedelta(hours=2), checkintime=now - timedelta(hours=1),
waswiped=True, scannedviruses=False))
db.session.commit()
response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()['data']
assert 'sanitized' in data['columns']
assert 'scanned_viruses' in data['columns']
checkin = [r for r in data['rows'] if r['action'] == 'check-in'][0]
checkout = [r for r in data['rows'] if r['action'] == 'check-out'][0]
assert checkin['sanitized'] == 'yes'
assert checkin['scanned_viruses'] == 'no'
# A take carries neither: both are recorded when the drive comes back.
assert checkout['sanitized'] == ''
assert checkout['scanned_viruses'] == ''
def test_unrecorded_wipe_is_blank_not_no(db, client, auth_headers):
"""A null means nobody wrote it down. Rendering that as 'no' would assert
something no one ever claimed, in the one record kept to be trustworthy."""
from plugins.usb.models import USBDevice, USBCheckout
from shopdb.core.models import Setting
from datetime import datetime, timedelta
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
valuetype='string', category='site'))
device = USBDevice(serialnumber='USB-10', label='Old stock')
db.session.add(device)
db.session.flush()
now = datetime.utcnow()
db.session.add(USBCheckout(
usbdeviceid=device.usbdeviceid, sso='270015376', checkoutname='Dana',
checkouttime=now - timedelta(hours=3), checkintime=now - timedelta(hours=2),
waswiped=None, scannedviruses=None))
db.session.commit()
response = client.get('/api/usb/reports/audit?days=7', headers=auth_headers)
checkin = [r for r in response.get_json()['data']['rows']
if r['action'] == 'check-in'][0]
assert checkin['sanitized'] == ''
assert checkin['scanned_viruses'] == ''