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:
99
tests/test_plugins/test_usb_import_pairing.py
Normal file
99
tests/test_plugins/test_usb_import_pairing.py
Normal 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
|
||||
Reference in New Issue
Block a user