Import USB records off an external cmmc_usb database, so a site can retire it

A site running external mode keeps its devices and check-in/out history in a
separate cmmc_usb database. There was no way to bring that in, so the choice
was to keep the second database forever or lose the history. This adds
'flask usb import-external' - dry run by default, --commit to write - which
reads through the same connection settings the routes already use and never
writes to the source.

Two things it has to reconcile, and they are why it is a command rather than a
few INSERTs.

The schemas disagree about SHAPE. External is an event log, one row per action;
self-hosted is a paired row carrying both times. Pairing walks each device in
time order, and real logs after years of use are untidy. The rule is that
nothing is invented and nothing vanishes: a drive still out becomes an open
checkout and is NOT flagged (that is just a drive somebody has), a return with
no matching issue is skipped and named, and two issues in a row leave the first
open rather than inventing a return time nobody recorded.

Identity is the other. usbdevices.serialnumber is unique and NOT NULL and the
external devices table has no serial, so device_id becomes it. Blank or
duplicated ids stop that device rather than inventing a key. external
device_owner has no column here, so it is appended to the device notes rather
than dropped.

Also adds usbcheckouts.scannedviruses, the one field with nowhere to land.
waswiped already means what external calls sanitized - selfhosted.py was
mapping it - but scanned_viruses was HARDCODED None in the log output: the API
promised the field and had nothing behind it, so an imported history would have
rendered blank. It now reads and writes properly.

Nullable, never defaulted to false. An unrecorded scan is not a failed scan,
and for removable-media records those are very different claims.

Full suite 2131 passed; seven new tests pin the pairing rules.
This commit is contained in:
cproudlock
2026-08-29 13:02:21 -04:00
parent 99c40ae7c3
commit 60c35728e8
7 changed files with 404 additions and 3 deletions

View File

@@ -0,0 +1,99 @@
"""Folding an external check-in/out LOG into self-hosted checkout PAIRS.
The external cmmc_usb database records one row per action; the self-hosted
usbcheckouts table records one row per checkout, carrying both times. The
import has to convert between them, and the interesting cases are all the ways
a real log is untidy after years of use: a drive still out, a return nobody
has a matching issue for, two issues in a row.
The rule these tests pin down is that NOTHING is invented and NOTHING vanishes
silently. An unclosed checkout stays unclosed; anything that cannot be paired
is reported. A quieter import that guessed the missing halves would produce a
tidier database and a false audit history, which for removable-media records is
the worst of the available outcomes.
"""
from plugins.usb.cli import _pair, _tri
def _row(action, when, badge='B1'):
return {'action': action, 'timestamp': when, 'badge_number': badge}
def test_a_clean_out_and_in_becomes_one_closed_checkout():
problems = []
pairs = _pair([_row('check-out', 1), _row('check-in', 2)], problems, 'DEV1')
assert len(pairs) == 1
out, back = pairs[0]
assert out['timestamp'] == 1
assert back['timestamp'] == 2
assert problems == []
def test_a_drive_still_out_stays_open_and_is_not_reported_as_a_problem():
# This is the normal state of any drive currently issued to somebody, not
# a data fault, so it must not appear in the problem list.
problems = []
pairs = _pair([_row('check-out', 1)], problems, 'DEV1')
assert len(pairs) == 1
out, back = pairs[0]
assert back is None
assert problems == []
def test_a_return_with_no_matching_issue_is_skipped_and_reported():
problems = []
pairs = _pair([_row('check-in', 5)], problems, 'DEV1')
assert pairs == []
assert len(problems) == 1
assert 'no matching check-out' in problems[0]
assert 'DEV1' in problems[0]
def test_two_issues_in_a_row_leave_the_first_open_rather_than_inventing_a_return():
problems = []
pairs = _pair([_row('check-out', 1, 'ALICE'),
_row('check-out', 2, 'BOB'),
_row('check-in', 3, 'BOB')], problems, 'DEV1')
assert len(pairs) == 2
first, second = pairs
assert first[0]['badge_number'] == 'ALICE'
assert first[1] is None, 'the first checkout must not be given a return time'
assert second[0]['badge_number'] == 'BOB'
assert second[1]['timestamp'] == 3
assert len(problems) == 1
assert 'still out' in problems[0]
def test_an_unrecognised_action_is_skipped_and_reported():
problems = []
pairs = _pair([_row('destroyed', 1)], problems, 'DEV1')
assert pairs == []
assert len(problems) == 1
assert 'unrecognised action' in problems[0]
def test_action_spelling_variants_are_accepted():
# Years of a hand-maintained log produce more than one spelling.
for out_word, in_word in (('check-out', 'check-in'),
('checkout', 'checkin'),
('out', 'in'),
('CHECK_OUT', 'RETURN')):
problems = []
pairs = _pair([_row(out_word, 1), _row(in_word, 2)], problems, 'DEV1')
assert len(pairs) == 1, out_word
assert pairs[0][1] is not None, out_word
assert problems == [], out_word
def test_unrecorded_flags_stay_unknown_rather_than_becoming_false():
# NULL in the source means nobody wrote it down. False would assert that
# the drive was checked and failed, which is a different claim entirely.
assert _tri(None) is None
assert _tri(0) is False
assert _tri(1) is True