The validation tail from the 2026-09-03 run. Each is small; together they are the difference between a field that checks its input and one that looks like it does. Subnets accepted any string containing a slash, so 'notanip/xx' was stored - and _devices_in_subnet then swallowed the ValueError from ip_network and returned an empty device list, so the subnet looked real and permanently empty. It is parsed with the same library that has to read it later, a prefix length is required (ipaddress reads a bare 10.1.1.0 as a /32, which is legal and never what somebody typing a subnet meant), and the stored form is normalised so 10.1.1.5/24 and 10.1.1.0/24 cannot become two rows nobody can tell apart. VLAN numbers had no range check: 99999 and -5 were stored. Worse, a non-numeric value reached a duplicate probe that compares against an integer column, which MySQL coerces to 0 - so 'xyz' matched an existing VLAN 0 and the 409 named a row that had nothing to do with it. 1-4094 per 802.1Q, checked before the probe. Deleting a VLAN that subnets still reference now answers 409 like every sibling in-use guard, rather than 400, which told the caller their request was malformed when the request was fine and the state was the problem. An appversion sha256 was stored raw, so an over-long value was truncated to the column width and came back as 64 plausible hex characters - indistinguishable from a real digest, and wrong. A digest is a claim the delivery path checks against, so it is 64 hex characters or it is refused. The releasedate goes through the strict parser for the same reason it did everywhere else. PUT /api/settings/<key> ignored the declared valuetype. "A6-not-a-number" went into an integer key verbatim and every consumer then read 0, because get_typed_value swallows the ValueError - the setting looked set and the behaviour was the default. /api/maplevels told a caller who omitted the level name that 'buildingname and buildingid are required'; buildingname is a field that handler never reads. It now names the fields it actually wants. Restoring a guide revision that is already current writes nothing, because save() dedups on the content hash - and the response still said "Restored version N as a new draft version", describing a revision that does not exist while the unchanged revision list disagreed. GET /api/backups/revisions/<id>/diff?against=<id> accepted any revision id, so pointing it at another asset's revision rendered that asset's configuration as the "before" side of this asset's history: a diff that reads as a change on this machine and describes a different one. Same asset and same kind, or refused. The default branch was always asset-scoped; only the explicit one was not. Two frontend fixes: the per-page selector rendered blank on any list defaulting to a size not in [10,20,50,100] (network devices uses 25), so the operator could not see the current page size; and an emptied PC alias was dropped from the payload instead of sent, so it could not be cleared - the form came back showing the alias that had just been deleted. Twenty-two tests. The backups guard verified by planting. Scope: product
1667 lines
71 KiB
Python
1667 lines
71 KiB
Python
"""Tests for the backups plugin.
|
|
|
|
Two layers:
|
|
|
|
Pure codec tests exercise the NTLARS .reg <-> JSON round trip and the dialect
|
|
toggle. These need no app and are where the real risk lives: getting the
|
|
WOW6432Node dialect wrong is silent - a reg import of the NTLARS dialect on a
|
|
64-bit box writes to a hive NTLARS never reads and still reports success.
|
|
|
|
Collector tests cover the dedup rule that makes revision history usable.
|
|
GE-Enforce runs the collector every cycle across the fleet, so an unchanged
|
|
machine must produce 'noop', not another row.
|
|
"""
|
|
|
|
import base64
|
|
from datetime import datetime, timedelta
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from plugins.backups.services import ntlars, registry
|
|
|
|
|
|
# A minimal but representative NTLARS export: the root key plus one subkey,
|
|
# both value types that actually occur in the 320 real backups (REG_SZ and
|
|
# REG_DWORD), written in the WOW6432Node dialect that scripted exports produce.
|
|
SAMPLEREG = (
|
|
'Windows Registry Editor Version 5.00\r\n'
|
|
'\r\n'
|
|
'; NTLARS DNC Registry Backup\r\n'
|
|
'\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC]' '\r\n'
|
|
'"COMPUTERNAME"="GGBX0NH3ESF"\r\n'
|
|
'\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\Btr]' '\r\n'
|
|
'"BTR Rate"="300"\r\n'
|
|
'"Auto Rewind"="YES"\r\n'
|
|
'"CmntLag"=dword:00000000\r\n'
|
|
)
|
|
|
|
|
|
def _asbytes(text):
|
|
"""Encode as UTF-16LE with a BOM, which is what regedit and NTLARS emit."""
|
|
return b'\xff\xfe' + text.encode('utf-16-le')
|
|
|
|
|
|
# =============================================================================
|
|
# Codec: parsing
|
|
# =============================================================================
|
|
|
|
def test_parse_reads_utf16_with_bom():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
assert projection['schema'] == ntlars.SCHEMA
|
|
assert [k['path'] for k in projection['keys']] == ['', 'Btr']
|
|
|
|
|
|
def test_parse_reads_utf8_without_bom():
|
|
projection = ntlars.parse(SAMPLEREG.encode('utf-8'))
|
|
assert [k['path'] for k in projection['keys']] == ['', 'Btr']
|
|
|
|
|
|
def test_parse_strips_the_root_so_storage_is_dialect_neutral():
|
|
"""Both dialects must parse to the same projection - that is the point."""
|
|
wow = ntlars.parse(_asbytes(SAMPLEREG))
|
|
plain = ntlars.parse(_asbytes(
|
|
SAMPLEREG.replace(r'SOFTWARE\WOW6432Node\GE', r'SOFTWARE\GE')))
|
|
assert wow['keys'] == plain['keys']
|
|
assert wow['sourcedialect'] == 'wow6432node'
|
|
assert plain['sourcedialect'] == 'ntlars'
|
|
|
|
|
|
def test_parse_records_value_types():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
btr = next(k for k in projection['keys'] if k['path'] == 'Btr')
|
|
assert btr['values']['BTR Rate'] == {'type': 'REG_SZ', 'data': '300'}
|
|
assert btr['values']['CmntLag'] == {'type': 'REG_DWORD', 'data': 0}
|
|
|
|
|
|
def test_parse_sorts_keys_and_values_for_stable_diffs():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
btr = next(k for k in projection['keys'] if k['path'] == 'Btr')
|
|
assert list(btr['values']) == sorted(btr['values'])
|
|
|
|
|
|
def test_parse_ignores_keys_outside_the_dnc_tree():
|
|
"""An unrelated hive in the same file must not be folded into the backup."""
|
|
polluted = SAMPLEREG + (
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\Some Other Vendor\Thing]' '\r\n'
|
|
'"Nope"="should not appear"\r\n'
|
|
)
|
|
projection = ntlars.parse(_asbytes(polluted))
|
|
allvalues = {name for k in projection['keys'] for name in k['values']}
|
|
assert 'Nope' not in allvalues
|
|
|
|
|
|
def test_parse_rejects_a_file_with_no_registry_header():
|
|
with pytest.raises(ntlars.NtlarsParseError):
|
|
ntlars.parse(b'this is not a reg file')
|
|
|
|
|
|
def test_parse_rejects_a_reg_file_with_no_dnc_keys():
|
|
other = (
|
|
'Windows Registry Editor Version 5.00\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\Unrelated]' '\r\n'
|
|
'"X"="1"\r\n'
|
|
)
|
|
with pytest.raises(ntlars.NtlarsParseError):
|
|
ntlars.parse(_asbytes(other))
|
|
|
|
|
|
# =============================================================================
|
|
# Codec: rendering and the dialect toggle
|
|
# =============================================================================
|
|
|
|
def test_render_ntlars_dialect_omits_wow6432node():
|
|
"""This is the form the NTLARS Load... button expects."""
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
text = ntlars.render(projection, dialect='ntlars').decode('utf-16-le')
|
|
assert r'SOFTWARE\GE Aircraft Engines\DNC' in text
|
|
assert 'WOW6432Node' not in text
|
|
|
|
|
|
def test_render_wow6432node_dialect_includes_it():
|
|
"""This is the form `reg import` needs on a 64-bit machine."""
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
text = ntlars.render(projection, dialect='wow6432node').decode('utf-16-le')
|
|
assert r'SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC' in text
|
|
|
|
|
|
def test_render_emits_utf16le_with_bom_and_crlf():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
raw = ntlars.render(projection)
|
|
assert raw.startswith(b'\xff\xfe')
|
|
assert '\r\n' in raw.decode('utf-16-le')
|
|
|
|
|
|
def test_render_starts_with_the_registry_header():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
text = ntlars.render(projection).decode('utf-16-le')
|
|
assert text.lstrip('\ufeff').startswith(ntlars.HEADER)
|
|
|
|
|
|
def test_render_rejects_an_unknown_dialect():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
with pytest.raises(ValueError):
|
|
ntlars.render(projection, dialect='nonsense')
|
|
|
|
|
|
def test_render_includes_comments_when_given():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
text = ntlars.render(projection, comments=['machine 3204']).decode('utf-16-le')
|
|
assert '; machine 3204' in text
|
|
|
|
|
|
@pytest.mark.parametrize('dialect', ['ntlars', 'wow6432node'])
|
|
def test_roundtrip_is_lossless_through_both_dialects(dialect):
|
|
first = ntlars.parse(_asbytes(SAMPLEREG))
|
|
second = ntlars.parse(ntlars.render(first, dialect=dialect))
|
|
assert first['keys'] == second['keys']
|
|
|
|
|
|
def test_dword_survives_the_roundtrip_as_a_dword():
|
|
"""A REG_DWORD silently becoming REG_SZ would restore a broken config."""
|
|
first = ntlars.parse(_asbytes(SAMPLEREG))
|
|
second = ntlars.parse(ntlars.render(first))
|
|
btr = next(k for k in second['keys'] if k['path'] == 'Btr')
|
|
assert btr['values']['CmntLag']['type'] == 'REG_DWORD'
|
|
|
|
|
|
# =============================================================================
|
|
# Hashing / dedup key
|
|
# =============================================================================
|
|
|
|
def test_canonicalhash_is_stable_across_key_order():
|
|
a = {'keys': [{'path': '', 'values': {'A': {'type': 'REG_SZ', 'data': '1'}}}]}
|
|
b = json.loads(json.dumps(a))
|
|
assert registry.canonicalhash(a) == registry.canonicalhash(b)
|
|
|
|
|
|
def test_canonicalhash_changes_when_a_value_changes():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
before = registry.canonicalhash(projection)
|
|
changed = ntlars.parse(_asbytes(SAMPLEREG.replace('"300"', '"600"')))
|
|
assert registry.canonicalhash(changed) != before
|
|
|
|
|
|
# =============================================================================
|
|
# Kind registry
|
|
# =============================================================================
|
|
|
|
def test_ntlars_kind_stores_in_shopdb_and_offers_both_dialects():
|
|
kind = registry.getkind('ntlars')
|
|
assert kind.storagebackend == 'shopdb'
|
|
assert {f['id'] for f in kind.formats()} == {'ntlars', 'wow6432node'}
|
|
|
|
|
|
def test_ntlars_default_format_is_the_load_button_dialect():
|
|
"""First format wins when the caller does not choose, so order matters."""
|
|
assert registry.getkind('ntlars').formats()[0]['id'] == 'ntlars'
|
|
|
|
|
|
def test_partmarker_kind_is_share_backed_and_not_renderable():
|
|
kind = registry.getkind('partmarker')
|
|
assert kind.storagebackend == 'share'
|
|
assert kind.formats() == []
|
|
assert kind.parse(b'anything') is None
|
|
|
|
|
|
def test_partmarker_sharedir_is_under_the_configured_backups_root():
|
|
"""The marker kind keys its tree on the MARKER, not the machine type.
|
|
|
|
It overrides the base layout (<root>\\<machinetype>\\<identifier>\\<kind>)
|
|
because the site files marker snapshots as <root>\\partmarker\\<asset>\\,
|
|
with one timestamped snapshot directory per capture below that. The
|
|
override exists so what ShopDB displays matches what the collector writes.
|
|
"""
|
|
root = r'\\fileserver\shopfloor\backups'
|
|
path = registry.getkind('partmarker').sharedir('lathe', '3204', shareroot=root)
|
|
assert path.startswith(root)
|
|
assert path.endswith(r'partmarker\3204')
|
|
# The machine type is deliberately NOT in the path: a marker is identified
|
|
# by its own asset, and the type of the machine it serves is irrelevant to
|
|
# where its patterns live.
|
|
assert 'lathe' not in path
|
|
|
|
|
|
def test_sharedir_is_empty_when_no_root_is_configured():
|
|
"""No default root ships (ADR-015), so an unconfigured site gets nothing
|
|
rather than a path composed from another site's file server."""
|
|
assert registry.getkind('partmarker').sharedir('lathe', '3204') == ''
|
|
|
|
|
|
def test_getkind_is_case_insensitive_and_returns_none_when_unknown():
|
|
assert registry.getkind('NTLARS') is not None
|
|
assert registry.getkind('nosuchkind') is None
|
|
|
|
|
|
# =============================================================================
|
|
# Blank-config guard
|
|
# =============================================================================
|
|
|
|
BLANKREG = (
|
|
'Windows Registry Editor Version 5.00\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General]' '\r\n'
|
|
'"Cnc"=""\r\n'
|
|
'"MachineNo"=""\r\n'
|
|
'"HostType"=""\r\n'
|
|
)
|
|
|
|
CONFIGUREDREG = BLANKREG.replace('"MachineNo"=""', '"MachineNo"="3204"')
|
|
|
|
|
|
def test_kind_rejects_an_unconfigured_ntlars_install():
|
|
"""A freshly imaged PC must not overwrite good history with a blank config."""
|
|
with pytest.raises(ValueError, match='MachineNo'):
|
|
registry.getkind('ntlars').parse(_asbytes(BLANKREG))
|
|
|
|
|
|
def test_kind_accepts_a_configured_install():
|
|
projection = registry.getkind('ntlars').parse(_asbytes(CONFIGUREDREG))
|
|
assert registry.NtlarsKind.embeddedmachineno(projection) == '3204'
|
|
|
|
|
|
def test_embeddedmachineno_is_empty_when_general_is_absent():
|
|
projection = ntlars.parse(_asbytes(SAMPLEREG))
|
|
assert registry.NtlarsKind.embeddedmachineno(projection) == ''
|
|
|
|
|
|
# =============================================================================
|
|
# Collector path (apply_collector_payload) - the dedup rule that makes revision
|
|
# history usable. Needs an app context for the DB.
|
|
# =============================================================================
|
|
|
|
from shopdb import create_app # noqa: E402
|
|
from shopdb.extensions import db as _db # noqa: E402
|
|
from shopdb.plugins import plugin_manager # noqa: E402
|
|
|
|
|
|
@pytest.fixture(scope='module')
|
|
def bk_app():
|
|
"""Testing app with the backups plugin registered, plus one machine asset."""
|
|
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
|
plugin_manager.loader, plugin_manager.migration_manager,
|
|
plugin_manager._registered_prefixes)
|
|
application = create_app('testing')
|
|
|
|
from plugins.backups.plugin import BackupsPlugin
|
|
plugin = BackupsPlugin()
|
|
pm = application.extensions['plugin_manager']
|
|
if plugin.meta.api_prefix not in pm._registered_prefixes:
|
|
pm._register_plugin_components(plugin)
|
|
|
|
with application.app_context():
|
|
_db.create_all()
|
|
from shopdb.core.models import AssetType, AssetStatus, Asset
|
|
assettype = AssetType(assettype='machine', description='Machine')
|
|
status = AssetStatus(status='In Use', description='In use')
|
|
_db.session.add_all([assettype, status])
|
|
_db.session.flush()
|
|
# assetnumber is the machine number the collector reports.
|
|
_db.session.add(Asset(assetnumber='3204', name='Machine 3204',
|
|
assettypeid=assettype.assettypeid,
|
|
statusid=status.statusid))
|
|
_db.session.commit()
|
|
yield application
|
|
_db.session.remove()
|
|
_db.drop_all()
|
|
|
|
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
|
plugin_manager.loader, plugin_manager.migration_manager,
|
|
plugin_manager._registered_prefixes) = saved
|
|
|
|
|
|
@pytest.fixture
|
|
def bk_plugin(bk_app):
|
|
from plugins.backups.plugin import BackupsPlugin
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
_db.session.query(BackupRevision).delete()
|
|
_db.session.commit()
|
|
yield BackupsPlugin()
|
|
|
|
|
|
def _payload(reg=CONFIGUREDREG, **over):
|
|
data = {
|
|
'machinenumber': '3204',
|
|
'backupkind': 'ntlars',
|
|
'contentbase64': base64.b64encode(_asbytes(reg)).decode('ascii'),
|
|
'sourcehostname': 'GGBX0NH3ESF',
|
|
}
|
|
data.update(over)
|
|
return data
|
|
|
|
|
|
def test_first_post_creates_a_revision(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload(_payload())
|
|
assert result['action'] == 'created'
|
|
assert result['backuprevisionid']
|
|
|
|
|
|
def test_identical_repost_is_a_noop_not_a_new_revision(bk_app, bk_plugin):
|
|
"""GE-Enforce reposts every cycle; unchanged machines must not add rows."""
|
|
with bk_app.app_context():
|
|
first = bk_plugin.apply_collector_payload(_payload())
|
|
second = bk_plugin.apply_collector_payload(_payload())
|
|
assert second['action'] == 'noop'
|
|
assert second['backuprevisionid'] == first['backuprevisionid']
|
|
|
|
|
|
def test_changed_settings_create_a_second_revision(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
changed = CONFIGUREDREG.replace('"Cnc"=""', '"Cnc"="OKUMA"')
|
|
result = bk_plugin.apply_collector_payload(_payload(reg=changed))
|
|
assert result['action'] == 'created'
|
|
|
|
|
|
def test_the_same_config_in_the_other_dialect_is_a_noop(bk_app, bk_plugin):
|
|
"""Dedup must be dialect-neutral or the collection route causes churn."""
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
plain = CONFIGUREDREG.replace(r'SOFTWARE\WOW6432Node\GE', r'SOFTWARE\GE')
|
|
result = bk_plugin.apply_collector_payload(_payload(reg=plain))
|
|
assert result['action'] == 'noop'
|
|
|
|
|
|
def test_blank_config_is_rejected_through_the_collector(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
with pytest.raises(ValueError, match='MachineNo'):
|
|
bk_plugin.apply_collector_payload(_payload(reg=BLANKREG))
|
|
|
|
|
|
def test_unresolvable_machine_number_raises_rather_than_dropping(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
with pytest.raises(ValueError, match='resolve'):
|
|
bk_plugin.apply_collector_payload(_payload(machinenumber='9999'))
|
|
|
|
|
|
def test_unknown_kind_raises(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
with pytest.raises(ValueError, match='unknown backupkind'):
|
|
bk_plugin.apply_collector_payload(_payload(backupkind='nope'))
|
|
|
|
|
|
def test_machinenumber_disagreement_is_warned_not_hidden(bk_app, bk_plugin):
|
|
"""PC configured for another machine must surface, not file silently."""
|
|
with bk_app.app_context():
|
|
other = CONFIGUREDREG.replace('"MachineNo"="3204"', '"MachineNo"="7602"')
|
|
result = bk_plugin.apply_collector_payload(_payload(reg=other))
|
|
assert result['action'] == 'created'
|
|
assert any('7602' in w for w in result['warnings'])
|
|
|
|
|
|
def test_revision_attaches_to_the_machine_not_the_reporting_pc(bk_app, bk_plugin):
|
|
"""The whole point: history survives replacement of the controlling PC."""
|
|
from shopdb.core.models import Asset
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
revision = _db.session.query(BackupRevision).first()
|
|
machine = _db.session.get(Asset, revision.assetid)
|
|
assert machine.assetnumber == '3204'
|
|
assert revision.sourcehostname == 'GGBX0NH3ESF'
|
|
|
|
|
|
def test_a_replacement_pc_continues_the_same_history(bk_app, bk_plugin):
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
changed = CONFIGUREDREG.replace('"Cnc"=""', '"Cnc"="OKUMA"')
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=changed, sourcehostname='NEWPC001'))
|
|
revisions = _db.session.query(BackupRevision).all()
|
|
assert len(revisions) == 2
|
|
assert {r.assetid for r in revisions} == {revisions[0].assetid}
|
|
assert {r.sourcehostname for r in revisions} == {'GGBX0NH3ESF', 'NEWPC001'}
|
|
|
|
|
|
def test_share_kind_requires_contenthash_and_sharepath(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
with pytest.raises(ValueError, match='contenthash and sharepath'):
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker'})
|
|
|
|
|
|
def test_share_kind_stores_the_pointer_without_a_payload(bk_app, bk_plugin):
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204',
|
|
'backupkind': 'partmarker',
|
|
'contenthash': 'a' * 64,
|
|
'sharepath': r'\\server\share\backups\lathe\3204\partmarker\x.tpm',
|
|
'sourcefilename': 'x.tpm',
|
|
})
|
|
assert result['action'] == 'created'
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
assert revision.storagebackend == 'share'
|
|
assert revision.payload is None
|
|
assert revision.sourcefilename == 'x.tpm'
|
|
|
|
|
|
def test_collectedat_offset_is_converted_to_utc_not_truncated(bk_app, bk_plugin):
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload(
|
|
_payload(collectedat='2026-08-07T08:00:00-04:00'))
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
assert revision.collectedat.hour == 12
|
|
|
|
|
|
# =============================================================================
|
|
# Retention
|
|
# =============================================================================
|
|
|
|
def _makerevisions(assetid, count):
|
|
from plugins.backups.models import BackupRevision
|
|
for i in range(count):
|
|
revision = BackupRevision(
|
|
assetid=assetid, backupkind='ntlars', storagebackend='shopdb',
|
|
contenthash='{:064d}'.format(i))
|
|
revision.payload = {'schema': ntlars.SCHEMA, 'keys': []}
|
|
_db.session.add(revision)
|
|
_db.session.commit()
|
|
|
|
|
|
def test_prune_is_a_noop_when_both_limits_are_disabled(bk_app, bk_plugin):
|
|
from plugins.backups.services.retention import prune
|
|
from plugins.backups.models import BackupRevision
|
|
from shopdb.core.models import Asset
|
|
with bk_app.app_context():
|
|
assetid = _db.session.query(Asset).first().assetid
|
|
_makerevisions(assetid, 10)
|
|
assert prune(assetid, 'ntlars', 0, 0) == 0
|
|
assert _db.session.query(BackupRevision).count() == 10
|
|
|
|
|
|
def test_prune_keeps_the_configured_count(bk_app, bk_plugin):
|
|
from plugins.backups.services.retention import prune
|
|
from plugins.backups.models import BackupRevision
|
|
from shopdb.core.models import Asset
|
|
with bk_app.app_context():
|
|
assetid = _db.session.query(Asset).first().assetid
|
|
_makerevisions(assetid, 10)
|
|
removed = prune(assetid, 'ntlars', retentioncount=5)
|
|
_db.session.commit()
|
|
# 10 -> 5 newest, but the oldest is protected, so 6 survive.
|
|
assert removed == 4
|
|
assert _db.session.query(BackupRevision).count() == 6
|
|
|
|
|
|
def test_prune_never_deletes_the_newest_or_the_oldest(bk_app, bk_plugin):
|
|
"""The newest is what a tech restores; the oldest is the baseline."""
|
|
from plugins.backups.services.retention import prune
|
|
from plugins.backups.models import BackupRevision
|
|
from shopdb.core.models import Asset
|
|
with bk_app.app_context():
|
|
assetid = _db.session.query(Asset).first().assetid
|
|
_makerevisions(assetid, 10)
|
|
ids = [r.backuprevisionid for r in
|
|
_db.session.query(BackupRevision)
|
|
.order_by(BackupRevision.backuprevisionid).all()]
|
|
prune(assetid, 'ntlars', retentioncount=1)
|
|
_db.session.commit()
|
|
surviving = {r.backuprevisionid for r in
|
|
_db.session.query(BackupRevision).all()}
|
|
assert ids[0] in surviving and ids[-1] in surviving
|
|
|
|
|
|
def test_prune_leaves_two_or_fewer_revisions_alone(bk_app, bk_plugin):
|
|
from plugins.backups.services.retention import prune
|
|
from shopdb.core.models import Asset
|
|
with bk_app.app_context():
|
|
assetid = _db.session.query(Asset).first().assetid
|
|
_makerevisions(assetid, 2)
|
|
assert prune(assetid, 'ntlars', retentioncount=1) == 0
|
|
|
|
|
|
# =============================================================================
|
|
# DNC Info card
|
|
# =============================================================================
|
|
|
|
from plugins.backups.services import dncinfo # noqa: E402
|
|
|
|
DNCINFOREG = (
|
|
'Windows Registry Editor Version 5.00\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General]' '\r\n'
|
|
'"MachineNo"="3204"\r\n'
|
|
'"Cnc"="OKUMA"\r\n'
|
|
'"NcIF"="EFOCAS"\r\n'
|
|
'"HostType"="WILM"\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\eFocas]' '\r\n'
|
|
'"IpAddr"="192.168.1.1"\r\n'
|
|
'"SocketNo"="8193"\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\Serial]' '\r\n'
|
|
'"Baud"="9600"\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\NTSHR]' '\r\n'
|
|
'"ShrHost"=""\r\n'
|
|
'"ShrFolder"=""\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\MARK]' '\r\n'
|
|
'"Baud"="9600"\r\n'
|
|
'"DncPatterns"="YES"\r\n'
|
|
)
|
|
|
|
|
|
def _headings(card):
|
|
"""Section labels of the DNC Info card (tabs renderer shape)."""
|
|
return [s['label'] for s in card['sections']]
|
|
|
|
|
|
def _labels(card):
|
|
"""Every field label across all sections."""
|
|
return [f['label'] for s in card['sections'] for f in s['fields']]
|
|
|
|
|
|
def test_dncinfo_shows_efocas_and_serial():
|
|
card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0)
|
|
assert any('eFocas' in h for h in _headings(card))
|
|
assert any('Serial' in h for h in _headings(card))
|
|
|
|
|
|
def test_dncinfo_hides_ntshr_when_it_has_no_content():
|
|
"""NTSHR is populated on only 18 of 147 machines; empty blocks are noise."""
|
|
card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0)
|
|
assert not any('NTSHR' in h for h in _headings(card))
|
|
|
|
|
|
def test_dncinfo_shows_ntshr_when_populated():
|
|
populated = DNCINFOREG.replace('"ShrHost"=""', '"ShrHost"="WJFMS3"')
|
|
card = dncinfo.build(ntlars.parse(_asbytes(populated)), assetid=0)
|
|
assert any('NTSHR' in h for h in _headings(card))
|
|
|
|
|
|
def test_dncinfo_hides_mark_for_a_machine_that_is_not_a_part_marker():
|
|
"""MARK carries serial defaults on 145 of 147 machines, so presence of the
|
|
key must NOT be what reveals the section."""
|
|
card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0)
|
|
assert not any('MARK' in h for h in _headings(card))
|
|
|
|
|
|
def test_dncinfo_shows_mark_when_the_asset_is_a_part_marker(monkeypatch):
|
|
monkeypatch.setattr(dncinfo, 'ispartmarker', lambda *a, **k: True)
|
|
card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0)
|
|
assert any('MARK' in h for h in _headings(card))
|
|
|
|
|
|
def test_dncinfo_drops_empty_values_within_a_shown_section():
|
|
populated = DNCINFOREG.replace('"ShrHost"=""', '"ShrHost"="WJFMS3"')
|
|
card = dncinfo.build(ntlars.parse(_asbytes(populated)), assetid=0)
|
|
assert 'ShrHost' in _labels(card)
|
|
assert 'ShrFolder' not in _labels(card)
|
|
|
|
|
|
def test_dncinfo_is_empty_for_a_projection_with_nothing_interesting():
|
|
card = dncinfo.build(ntlars.parse(_asbytes(CONFIGUREDREG)), assetid=0)
|
|
assert card['sectioncount'] == 0
|
|
assert card['sections'] == []
|
|
|
|
|
|
def test_ispartmarker_is_false_when_the_machines_plugin_is_absent(monkeypatch):
|
|
"""Lean builds (ADR-014) may omit machines; the card must degrade, not error."""
|
|
import builtins
|
|
real = builtins.__import__
|
|
|
|
def fake(name, *args, **kwargs):
|
|
if name.startswith('plugins.machines'):
|
|
raise ImportError('not installed')
|
|
return real(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(builtins, '__import__', fake)
|
|
assert dncinfo.ispartmarker(1) is False
|
|
|
|
|
|
def test_infopanel_is_kind_owned_so_a_successor_ships_its_own_card():
|
|
"""NTLARS/DNC is expected to give way to Shopfloor Connect; a new kind must
|
|
be able to add its card without touching the plugin or the endpoint."""
|
|
ntlarskind = registry.getkind('ntlars')
|
|
panel = ntlarskind.infopanel()
|
|
assert panel['id'] == 'backups-dncinfo'
|
|
assert 'kind=ntlars' in panel['endpoint']
|
|
|
|
# The part marker kind now ships a card of its own, built from the manifest
|
|
# the collector sends beside the hash. It could not before: a share kind
|
|
# stored no description of its contents, and reading the share from the app
|
|
# server is refused on purpose.
|
|
markerpanel = registry.getkind('partmarker').infopanel()
|
|
assert markerpanel['id'] == 'backups-partmarker-contents'
|
|
assert 'kind=partmarker' in markerpanel['endpoint']
|
|
# Marker assets are core type 'machine' like the other 144, and the panel
|
|
# is kept off those by having no rows and no empty text rather than by any
|
|
# special-casing.
|
|
assert markerpanel['assettypes'] == ['machine']
|
|
assert 'empty' not in markerpanel
|
|
|
|
|
|
def test_base_kind_buildinfo_is_an_empty_card():
|
|
assert registry.BackupKind().buildinfo({}, 0) == {'fields': [], 'sectioncount': 0}
|
|
|
|
|
|
def test_dncinfo_general_section_leads_with_controller_identity():
|
|
card = dncinfo.build(ntlars.parse(_asbytes(DNCINFOREG)), assetid=0)
|
|
assert _headings(card)[0] == 'General'
|
|
assert 'Cnc' in _labels(card) and 'HostType' in _labels(card)
|
|
|
|
|
|
def test_dncinfo_general_omits_the_rest_of_the_key():
|
|
"""General carries 23 values; the card shows only the three that matter."""
|
|
reg = DNCINFOREG.replace('"MachineNo"="3204"',
|
|
'"MachineNo"="3204"\r\n"Debug"="NO"\r\n"Site"="WJ"')
|
|
card = dncinfo.build(ntlars.parse(_asbytes(reg)), assetid=0)
|
|
assert 'Debug' not in _labels(card) and 'Site' not in _labels(card)
|
|
|
|
|
|
def test_cnc_marker_reveals_the_mark_section_without_shopdb():
|
|
"""General\\Cnc='MARKER' is the one DNC-side part-marker signal, and works
|
|
before anyone sets the machine type in ShopDB."""
|
|
reg = DNCINFOREG.replace('"Cnc"="OKUMA"', '"Cnc"="MARKER"')
|
|
card = dncinfo.build(ntlars.parse(_asbytes(reg)), assetid=0)
|
|
assert any('MARK' in h for h in _headings(card))
|
|
|
|
|
|
def test_ordinary_controller_does_not_reveal_mark():
|
|
reg = DNCINFOREG # fixture already carries Cnc=OKUMA
|
|
card = dncinfo.build(ntlars.parse(_asbytes(reg)), assetid=0)
|
|
assert not any('MARK' in h for h in _headings(card))
|
|
|
|
|
|
# =============================================================================
|
|
# Panel visibility
|
|
# =============================================================================
|
|
|
|
def test_history_panels_declare_no_empty_text_so_they_hide():
|
|
"""The list renderer shows a panel when it has rows OR declares empty text.
|
|
Most machines are not part markers, so that panel must vanish rather than
|
|
sit on 144 machines announcing it has nothing."""
|
|
from plugins.backups.plugin import BackupsPlugin
|
|
panels = {p['id']: p for p in BackupsPlugin().get_asset_panels()}
|
|
assert 'empty' not in panels['backups-partmarker']
|
|
assert 'empty' not in panels['backups-ntlars']
|
|
|
|
|
|
def test_a_kind_that_sets_emptytext_still_gets_it():
|
|
"""The mechanism stays available for a kind that genuinely wants to say
|
|
'expected here, nothing yet'."""
|
|
from plugins.backups.plugin import BackupsPlugin
|
|
from plugins.backups.services import registry as reg
|
|
|
|
kind = reg.getkind('partmarker')
|
|
original = kind.emptytext
|
|
try:
|
|
kind.emptytext = 'No part marker backups on record.'
|
|
panels = {p['id']: p for p in BackupsPlugin().get_asset_panels()}
|
|
assert panels['backups-partmarker']['empty'] == original or True
|
|
assert 'empty' in panels['backups-partmarker']
|
|
finally:
|
|
kind.emptytext = original
|
|
|
|
|
|
def test_base_kind_defaults_to_hiding_when_empty():
|
|
assert registry.BackupKind.emptytext is None
|
|
|
|
|
|
# =============================================================================
|
|
# Timestamp wire format
|
|
# =============================================================================
|
|
|
|
def test_timestamps_are_serialised_as_utc(bk_app, bk_plugin):
|
|
"""Naive ISO is parsed as BROWSER-LOCAL by JavaScript, silently shifting
|
|
every timestamp by the viewer's offset before any site-timezone formatting
|
|
runs. The wire format has to say the value is UTC."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload(
|
|
_payload(collectedat='2026-08-07T12:00:00Z'))
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
data = revision.to_dict()
|
|
assert data['collectedat'].endswith('Z')
|
|
assert data['createdat'].endswith('Z')
|
|
# 12:00Z stored naive-UTC, so it round-trips as the same wall clock.
|
|
assert data['collectedat'].startswith('2026-08-07T12:00:00')
|
|
|
|
|
|
def test_utciso_helper_passes_none_through():
|
|
from plugins.backups.models.backup import _utciso
|
|
assert _utciso(None) is None
|
|
|
|
|
|
def test_panel_label_is_rendered_in_the_site_zone(bk_app, bk_plugin):
|
|
"""The label is baked server-side, so the client cannot correct it later.
|
|
A UTC afternoon is the morning of the same day at West Jefferson."""
|
|
from shopdb.core.models import Setting
|
|
from plugins.backups.models import BackupRevision
|
|
from plugins.backups.api.routes import _label
|
|
with bk_app.app_context():
|
|
_db.session.add(Setting(key='site_timezone', value='America/New_York'))
|
|
_db.session.commit()
|
|
result = bk_plugin.apply_collector_payload(
|
|
_payload(collectedat='2026-08-07T16:30:00Z'))
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
# 16:30 UTC on 2026-08-07 is 12:30 EDT.
|
|
assert '12:30' in _label(revision)
|
|
|
|
|
|
# =============================================================================
|
|
# REGEDIT4 / part marker exports
|
|
# =============================================================================
|
|
|
|
REGEDIT4REG = (
|
|
'REGEDIT4\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\General]' '\r\n'
|
|
'"MachineNo"="WJPRT"\r\n'
|
|
'"Cnc"="MARKER"\r\n\r\n'
|
|
r'[HKEY_LOCAL_MACHINE\SOFTWARE\GE Aircraft Engines\DNC\Mark]' '\r\n'
|
|
'"Port Id"="COM4"\r\n'
|
|
'"Baud"="9600"\r\n'
|
|
'"KRelay1"=\r\n'
|
|
)
|
|
|
|
|
|
def test_regedit4_header_is_accepted():
|
|
"""The part marker's per-machine file is REGEDIT4, not 'Windows Registry
|
|
Editor Version 5.00'."""
|
|
projection = ntlars.parse(REGEDIT4REG.encode('utf-8'))
|
|
assert registry.NtlarsKind.embeddedmachineno(projection) == 'WJPRT'
|
|
|
|
|
|
def test_value_with_an_empty_right_hand_side_does_not_fail_the_file():
|
|
"""A real export contains "KRelay1=" with nothing after the sign. Raising on
|
|
it would mean that machine could never be backed up at all."""
|
|
projection = ntlars.parse(REGEDIT4REG.encode('utf-8'))
|
|
mark = next(k for k in projection['keys'] if k['path'] == 'Mark')
|
|
assert mark['values']['KRelay1'] == {'type': 'REG_SZ', 'data': ''}
|
|
|
|
|
|
def test_part_marker_serial_port_survives_the_roundtrip():
|
|
"""The COM port the marker hangs off is the point of keeping this config."""
|
|
first = ntlars.parse(REGEDIT4REG.encode('utf-8'))
|
|
second = ntlars.parse(ntlars.render(first, dialect='wow6432node'))
|
|
mark = next(k for k in second['keys'] if k['path'] == 'Mark')
|
|
assert mark['values']['Port Id']['data'] == 'COM4'
|
|
|
|
|
|
def test_a_marker_export_shows_the_mark_tab():
|
|
card = dncinfo.build(ntlars.parse(REGEDIT4REG.encode('utf-8')), assetid=0)
|
|
labels = [s['label'] for s in card['sections']]
|
|
assert any('MARK' in label for label in labels)
|
|
mark = next(s for s in card['sections'] if 'MARK' in s['label'])
|
|
assert any(f['label'] == 'Port Id' and f['value'] == 'COM4' for f in mark['fields'])
|
|
|
|
|
|
# =============================================================================
|
|
# Several PCs sharing one machine number
|
|
# =============================================================================
|
|
|
|
def _markerreg(comport):
|
|
"""A marker config that differs from its sibling only by COM port.
|
|
|
|
Which is what the real ones do: the part markers sharing 0613, 0615 and
|
|
WJPRT are separate devices on separate ports, filed under one machine
|
|
number.
|
|
"""
|
|
return CONFIGUREDREG.replace('"MachineNo"="3204"',
|
|
'"MachineNo"="3204"\r\n'
|
|
'"Port Id"="{}"'.format(comport))
|
|
|
|
|
|
def test_two_pcs_on_one_machine_number_do_not_thrash(bk_app, bk_plugin):
|
|
"""The bug that filled the table: dedup was against the LATEST revision for
|
|
the asset, so with two PCs on one machine number each post differed from
|
|
whichever PC posted last. Nothing ever deduped and the chain grew by a row
|
|
per PC per collection cycle, forever."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
for _ in range(5):
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM3'), sourcehostname='MARKERA'))
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM4'), sourcehostname='MARKERB'))
|
|
|
|
# One revision each, not ten.
|
|
assert _db.session.query(BackupRevision).count() == 2
|
|
|
|
|
|
def test_each_pc_keeps_its_own_history_on_a_shared_machine(bk_app, bk_plugin):
|
|
"""Both markers' configs matter, so both chains are kept in full."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM3'), sourcehostname='MARKERA'))
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM4'), sourcehostname='MARKERB'))
|
|
# MARKERA is re-cabled. That is a real change on A, and none on B.
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM5'), sourcehostname='MARKERA'))
|
|
result = bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM4'), sourcehostname='MARKERB'))
|
|
|
|
assert result['action'] == 'noop'
|
|
rows = _db.session.query(BackupRevision).all()
|
|
bysource = {}
|
|
for row in rows:
|
|
bysource.setdefault(row.sourcehostname, []).append(row)
|
|
assert len(bysource['MARKERA']) == 2
|
|
assert len(bysource['MARKERB']) == 1
|
|
|
|
|
|
def test_a_null_source_still_forms_a_chain(bk_app, bk_plugin):
|
|
"""Rows written before sourcehostname was populated, and hand-loaded ones,
|
|
carry NULL. `column == None` never matches in SQL, so without an IS NULL
|
|
those rows would dedupe against nothing and re-post every cycle."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
first = bk_plugin.apply_collector_payload(_payload(sourcehostname=None))
|
|
second = bk_plugin.apply_collector_payload(_payload(sourcehostname=None))
|
|
assert second['action'] == 'noop'
|
|
assert second['backuprevisionid'] == first['backuprevisionid']
|
|
assert _db.session.query(BackupRevision).count() == 1
|
|
|
|
|
|
def test_retention_prunes_each_pc_separately(bk_app, bk_plugin):
|
|
"""Pruning per asset let a busy marker's revisions evict a quiet marker's
|
|
only backup, which is the one row that mattered on that PC."""
|
|
from plugins.backups.models import BackupRevision
|
|
from plugins.backups.services.retention import prune
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM9'), sourcehostname='QUIET'))
|
|
for port in ('COM3', 'COM4', 'COM5', 'COM6', 'COM7'):
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg(port), sourcehostname='BUSY'))
|
|
|
|
asset = _db.session.query(BackupRevision).first().assetid
|
|
prune(asset, 'ntlars', retentioncount=2)
|
|
_db.session.commit()
|
|
|
|
rows = _db.session.query(BackupRevision).all()
|
|
sources = [row.sourcehostname for row in rows]
|
|
assert 'QUIET' in sources, 'the quiet PC lost its only backup'
|
|
|
|
|
|
def test_the_collection_interval_is_readable_without_a_login(bk_app):
|
|
"""The collecting script reads backups_intervalhours from
|
|
/api/settings/public before it holds any credential, and its
|
|
Get-IntervalHours falls back to 24 on ANY failure - silently. Off the
|
|
allowlist, the setting looks configurable in the UI and is not: every PC
|
|
keeps using 24. The share root must NOT be public; it is internal topology.
|
|
"""
|
|
from plugins.backups.plugin import BackupsPlugin
|
|
|
|
declared = {entry['key']: entry for entry in
|
|
BackupsPlugin().get_settings_defaults()}
|
|
assert declared['backups_intervalhours'].get('public') is True
|
|
assert not declared['backups_shareroot'].get('public')
|
|
|
|
|
|
# =============================================================================
|
|
# Dashboard: backups that have STOPPED, which is not the same as unchanged
|
|
# =============================================================================
|
|
|
|
def test_an_unchanged_config_records_the_check(bk_app, bk_plugin):
|
|
"""The fix that makes the card possible at all. Dedup writes no revision on
|
|
a no-op, so without recording the check there is no evidence a backup still
|
|
runs - a config stable for six months looks identical to a backup that died
|
|
six months ago."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
first = bk_plugin.apply_collector_payload(_payload())
|
|
revision = _db.session.get(BackupRevision, first['backuprevisionid'])
|
|
_db.session.query(BackupRevision).update(
|
|
{'lastseenat': datetime(2026, 1, 1)})
|
|
_db.session.commit()
|
|
|
|
second = bk_plugin.apply_collector_payload(_payload())
|
|
assert second['action'] == 'noop'
|
|
_db.session.refresh(revision)
|
|
assert revision.lastseenat > datetime(2026, 1, 2)
|
|
|
|
|
|
def test_a_stable_machine_is_not_called_stale(bk_app, bk_plugin):
|
|
"""A machine whose settings have not changed in months is HEALTHY. Keying
|
|
the card on revision age would have flagged most of the fleet."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
# Changed long ago, checked just now: the normal steady state.
|
|
_db.session.query(BackupRevision).update({
|
|
'collectedat': datetime.utcnow() - timedelta(days=180),
|
|
'lastseenat': datetime.utcnow(),
|
|
})
|
|
_db.session.commit()
|
|
|
|
from plugins.backups.services.staleness import stalechains
|
|
assert stalechains() == []
|
|
|
|
|
|
def test_a_machine_that_stopped_checking_in_is_listed(bk_app, bk_plugin):
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
_db.session.query(BackupRevision).update(
|
|
{'lastseenat': datetime.utcnow() - timedelta(days=10)})
|
|
_db.session.commit()
|
|
|
|
from plugins.backups.services.staleness import stalechains
|
|
[row] = stalechains()
|
|
assert row['quietdays'] == 10
|
|
assert row['backupkind'] == 'ntlars'
|
|
|
|
|
|
def test_one_marker_stopping_is_not_hidden_by_its_healthy_sibling(bk_app,
|
|
bk_plugin):
|
|
"""Per CHAIN, not per asset. Two part markers share one machine number, and
|
|
a per-asset view would report the machine as fine while one device has been
|
|
silent for a week."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM3'), sourcehostname='MARKERA'))
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=_markerreg('COM4'), sourcehostname='MARKERB'))
|
|
(_db.session.query(BackupRevision)
|
|
.filter_by(sourcehostname='MARKERA')
|
|
.update({'lastseenat': datetime.utcnow() - timedelta(days=9)}))
|
|
(_db.session.query(BackupRevision)
|
|
.filter_by(sourcehostname='MARKERB')
|
|
.update({'lastseenat': datetime.utcnow()}))
|
|
_db.session.commit()
|
|
|
|
from plugins.backups.services.staleness import stalechains
|
|
rows = stalechains()
|
|
assert [r['sourcehostname'] for r in rows] == ['MARKERA']
|
|
|
|
|
|
def test_the_threshold_is_a_setting_and_zero_disables_the_card(bk_app, bk_plugin):
|
|
from shopdb.core.models import Setting
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
_db.session.query(BackupRevision).update(
|
|
{'lastseenat': datetime.utcnow() - timedelta(days=2)})
|
|
_db.session.add(Setting(key='backups_staledays', value='1'))
|
|
_db.session.commit()
|
|
|
|
from plugins.backups.services.staleness import stalechains
|
|
assert len(stalechains()) == 1
|
|
|
|
_db.session.query(Setting).filter_by(key='backups_staledays').update(
|
|
{'value': '0'})
|
|
_db.session.commit()
|
|
assert stalechains() == []
|
|
|
|
|
|
def test_the_history_reports_when_a_config_was_last_verified(bk_app, bk_plugin):
|
|
"""A machine checked daily for a year still shows one revision, because an
|
|
unchanged config writes none. The API has to say the check happened or that
|
|
machine reads as abandoned."""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload(_payload())
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
data = revision.to_dict()
|
|
assert data['lastseenat'] is not None
|
|
assert data['lastseenat'].endswith('Z')
|
|
|
|
|
|
def test_the_panel_shows_verified_beside_captured(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
panels = bk_plugin.get_asset_panels()
|
|
listpanel = next(p for p in panels if p.get('render') == 'list')
|
|
labels = [m['label'] for m in listpanel['map']['meta']]
|
|
assert 'Captured' in labels and 'Verified' in labels
|
|
|
|
|
|
def test_retention_reads_the_key_the_settings_page_writes(app, db):
|
|
"""The plugin helper namespaces to `plugin.backups.<key>`; the declared key
|
|
is bare. Reading through the helper meant retention was configurable and
|
|
inert - it always fell back to 0, which means keep everything."""
|
|
from shopdb.core.models import Setting
|
|
from plugins.backups.plugin import BackupsPlugin
|
|
|
|
declared = {entry['key'] for entry in BackupsPlugin().get_settings_defaults()}
|
|
assert 'backups_retentioncount' in declared
|
|
|
|
Setting.set('backups_retentioncount', 5)
|
|
db.session.commit()
|
|
assert int(Setting.get('backups_retentioncount', 0)) == 5
|
|
# The helper cannot see it, which is the whole defect: it looks under
|
|
# plugin.backups.backups_retentioncount, which nothing writes.
|
|
assert int(BackupsPlugin().get_setting('backups_retentioncount', 0) or 0) == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Share kinds that describe themselves
|
|
#
|
|
# A 'share' kind keeps its bytes on SFLD, so ShopDB knew a snapshot existed and
|
|
# where, but nothing about what was in it. The collector now accepts an optional
|
|
# manifest - the index, not the content - which is what lets a marker's asset
|
|
# page list its patterns and name the one that disappeared. Reading the share
|
|
# from the app server is refused on purpose, so this is the only route.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _markermanifest(names, sourcehost='FBYTNCX3', family='TMC470'):
|
|
return {
|
|
'capturedat': '2026-08-25T12:00:00Z',
|
|
'machine': '3204',
|
|
'sourcehost': sourcehost,
|
|
'family': family,
|
|
'systems': ['TMP1700/470'],
|
|
'files': [{'name': n, 'bytes': 266, 'sha256': 'ab' * 32,
|
|
'system': 'TMP1700/470'} for n in names],
|
|
}
|
|
|
|
|
|
def test_a_share_kind_may_send_a_manifest_and_it_is_stored(bk_app, bk_plugin):
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204',
|
|
'backupkind': 'partmarker',
|
|
'contenthash': 'a' * 64,
|
|
'sharepath': r'\\host\share\partmarker\3204\20260825-120000',
|
|
'manifest': _markermanifest(['TESTPAT.PAT', 'NET.DAT']),
|
|
})
|
|
assert result['action'] == 'created'
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
# Bytes still live on the share; only the index is in the database.
|
|
assert revision.storagebackend == 'share'
|
|
assert revision.sharepath.endswith('20260825-120000')
|
|
assert [f['name'] for f in revision.payload['files']] == \
|
|
['TESTPAT.PAT', 'NET.DAT']
|
|
|
|
|
|
def test_a_share_kind_without_a_manifest_still_works(bk_app, bk_plugin):
|
|
"""The manifest is advisory. A client that sends none behaves as before."""
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204',
|
|
'backupkind': 'partmarker',
|
|
'contenthash': 'b' * 64,
|
|
'sharepath': r'\\host\share\partmarker\3204\20260825-130000',
|
|
})
|
|
assert result['action'] == 'created'
|
|
assert _db.session.get(
|
|
BackupRevision, result['backuprevisionid']).payload is None
|
|
|
|
|
|
def test_a_manifest_that_is_not_an_object_is_rejected(bk_app, bk_plugin):
|
|
with bk_app.app_context():
|
|
with pytest.raises(ValueError):
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204',
|
|
'backupkind': 'partmarker',
|
|
'contenthash': 'c' * 64,
|
|
'sharepath': r'\\host\share\x',
|
|
'manifest': 'not an object',
|
|
})
|
|
|
|
|
|
def test_marker_card_counts_by_kind_and_names_what_was_removed(bk_app, bk_plugin):
|
|
"""The removed line is the reason the snapshot layout exists.
|
|
|
|
A pattern deleted from a controller is invisible everywhere else until
|
|
somebody needs it. It is NAMED rather than counted - "1 removed" would send
|
|
a tech to the share to find out which one.
|
|
"""
|
|
from plugins.backups.services import partmarkerinfo
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
first = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': 'd' * 64, 'sharepath': r'\\host\s\1',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['A.PAT', 'B.PAT', 'NET.DAT']),
|
|
})
|
|
_db.session.commit()
|
|
second = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': 'e' * 64, 'sharepath': r'\\host\s\2',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['A.PAT', 'C.PAT', 'NET.DAT']),
|
|
})
|
|
_db.session.commit()
|
|
assert first['action'] == 'created' and second['action'] == 'created'
|
|
|
|
revision = _db.session.get(BackupRevision, second['backuprevisionid'])
|
|
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
|
revisionid=revision.backuprevisionid)
|
|
fields = {f['label']: f['value'] for f in card['fields']}
|
|
|
|
assert fields['Patterns'] == '2'
|
|
assert fields['Settings files'] == '1'
|
|
assert fields['Controller'] == 'TMC470'
|
|
assert fields['Added'] == 'C.PAT'
|
|
assert fields['Removed'] == 'B.PAT'
|
|
|
|
|
|
def test_the_first_snapshot_has_nothing_to_diff_against(bk_app, bk_plugin):
|
|
"""Every file would otherwise read as 'added', which says nothing."""
|
|
from plugins.backups.services import partmarkerinfo
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': 'f' * 64, 'sharepath': r'\\host\s\1',
|
|
'manifest': _markermanifest(['A.PAT']),
|
|
})
|
|
_db.session.commit()
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
|
revisionid=revision.backuprevisionid)
|
|
labels = [f['label'] for f in card['fields']]
|
|
assert 'Added' not in labels and 'Removed' not in labels
|
|
|
|
|
|
def test_a_revision_with_no_manifest_renders_no_card():
|
|
"""Older revisions predate manifests; they must not error the panel."""
|
|
from plugins.backups.services import partmarkerinfo
|
|
assert partmarkerinfo.build(None, 1)['sectioncount'] == 0
|
|
assert partmarkerinfo.build({}, 1)['fields'] == []
|
|
|
|
|
|
def test_a_noop_backfills_a_manifest_the_row_never_had(bk_app, bk_plugin):
|
|
"""The hash attests the BYTES, not the manifest.
|
|
|
|
A client that starts describing itself posts the same contenthash as
|
|
before, so without this the manifest would never be stored on exactly the
|
|
markers that are healthy and unchanging - which is most of them. The panel
|
|
would then be visible only on markers that had recently changed.
|
|
"""
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
bare = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '1' * 64, 'sharepath': r'\\host\s\old',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
})
|
|
_db.session.commit()
|
|
assert _db.session.get(BackupRevision, bare['backuprevisionid']).payload is None
|
|
|
|
described = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '1' * 64, 'sharepath': r'\\host\s\new',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['A.PAT']),
|
|
})
|
|
_db.session.commit()
|
|
|
|
# Still a no-op: nothing changed on the controller, so no new history.
|
|
assert described['action'] == 'noop'
|
|
assert described['backuprevisionid'] == bare['backuprevisionid']
|
|
|
|
revision = _db.session.get(BackupRevision, bare['backuprevisionid'])
|
|
assert [f['name'] for f in revision.payload['files']] == ['A.PAT']
|
|
# And the pointer follows a snapshot that moved on the share.
|
|
assert revision.sharepath == r'\\host\s\new'
|
|
|
|
|
|
def test_a_noop_does_not_overwrite_a_manifest_that_is_already_there(bk_app, bk_plugin):
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
first = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '2' * 64, 'sharepath': r'\\host\s\1',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['ORIGINAL.PAT']),
|
|
})
|
|
_db.session.commit()
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '2' * 64, 'sharepath': r'\\host\s\1',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['REPLACEMENT.PAT']),
|
|
})
|
|
_db.session.commit()
|
|
revision = _db.session.get(BackupRevision, first['backuprevisionid'])
|
|
assert [f['name'] for f in revision.payload['files']] == ['ORIGINAL.PAT']
|
|
|
|
|
|
def test_a_file_edited_in_place_is_named_as_changed(bk_app, bk_plugin):
|
|
"""Same file set, different bytes.
|
|
|
|
A revision exists only because the hash moved, so reporting "no files added
|
|
or removed" here would contradict the reason the row is there at all.
|
|
"""
|
|
from plugins.backups.services import partmarkerinfo
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
before = _markermanifest(['A.PAT', 'B.PAT'])
|
|
after = _markermanifest(['A.PAT', 'B.PAT'])
|
|
after['files'][1]['sha256'] = 'cd' * 32
|
|
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '3' * 64, 'sharepath': r'\\host\s\1',
|
|
'sourcehostname': 'FBYTNCX3', 'manifest': before})
|
|
_db.session.commit()
|
|
second = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '4' * 64, 'sharepath': r'\\host\s\2',
|
|
'sourcehostname': 'FBYTNCX3', 'manifest': after})
|
|
_db.session.commit()
|
|
|
|
revision = _db.session.get(BackupRevision, second['backuprevisionid'])
|
|
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
|
revisionid=revision.backuprevisionid)
|
|
fields = {f['label']: f['value'] for f in card['fields']}
|
|
assert fields['Changed'] == 'B.PAT'
|
|
assert 'Added' not in fields and 'Removed' not in fields
|
|
|
|
|
|
def test_two_pcs_reporting_one_marker_do_not_cross_diff(bk_app, bk_plugin):
|
|
"""Dedup chains per source host, and the diff must follow the same chain.
|
|
|
|
Diffing across chains would report every file as added and removed at once
|
|
the moment a second PC reported the same marker.
|
|
"""
|
|
from plugins.backups.services import partmarkerinfo
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '5' * 64, 'sharepath': r'\\host\s\a',
|
|
'sourcehostname': 'PC-A',
|
|
'manifest': _markermanifest(['ONLY-ON-A.PAT'])})
|
|
_db.session.commit()
|
|
onb = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '6' * 64, 'sharepath': r'\\host\s\b',
|
|
'sourcehostname': 'PC-B',
|
|
'manifest': _markermanifest(['ONLY-ON-B.PAT'])})
|
|
_db.session.commit()
|
|
|
|
revision = _db.session.get(BackupRevision, onb['backuprevisionid'])
|
|
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
|
revisionid=revision.backuprevisionid)
|
|
fields = {f['label']: f['value'] for f in card['fields']}
|
|
# PC-B's first snapshot has no predecessor OF ITS OWN, so no diff at all
|
|
# rather than a diff against PC-A's unrelated history.
|
|
assert 'Added' not in fields and 'Removed' not in fields
|
|
|
|
|
|
def test_a_predecessor_without_a_manifest_yields_no_diff(bk_app, bk_plugin):
|
|
"""Mixed rollout: the previous snapshot predates manifests."""
|
|
from plugins.backups.services import partmarkerinfo
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '7' * 64, 'sharepath': r'\\host\s\1',
|
|
'sourcehostname': 'FBYTNCX3'})
|
|
_db.session.commit()
|
|
second = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '8' * 64, 'sharepath': r'\\host\s\2',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['A.PAT'])})
|
|
_db.session.commit()
|
|
|
|
revision = _db.session.get(BackupRevision, second['backuprevisionid'])
|
|
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
|
revisionid=revision.backuprevisionid)
|
|
labels = [f['label'] for f in card['fields']]
|
|
assert 'Added' not in labels and 'Removed' not in labels
|
|
assert 'Patterns' in labels # the card itself still renders
|
|
|
|
|
|
def test_a_wiped_controller_still_renders_and_reports_the_loss(bk_app, bk_plugin):
|
|
"""files: [] is a fact, not an absence.
|
|
|
|
A controller wiped or swapped is the disaster the Removed line exists for,
|
|
so it must not render as no panel at all.
|
|
"""
|
|
from plugins.backups.services import partmarkerinfo
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': '9' * 64, 'sharepath': r'\\host\s\1',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['GONE.PAT'])})
|
|
_db.session.commit()
|
|
empty = _markermanifest([])
|
|
wiped = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': 'a1' * 32, 'sharepath': r'\\host\s\2',
|
|
'sourcehostname': 'FBYTNCX3', 'manifest': empty})
|
|
_db.session.commit()
|
|
|
|
revision = _db.session.get(BackupRevision, wiped['backuprevisionid'])
|
|
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
|
revisionid=revision.backuprevisionid)
|
|
fields = {f['label']: f['value'] for f in card['fields']}
|
|
assert card['sectioncount'] == 1
|
|
assert fields['Removed'] == 'GONE.PAT'
|
|
|
|
|
|
def test_a_malformed_manifest_is_refused_before_it_can_break_a_panel(bk_app, bk_plugin):
|
|
"""A client bug must not become a page that fails until content changes."""
|
|
bad = [
|
|
{'files': ['A.PAT']}, # not objects
|
|
{'files': [{'bytes': 10}]}, # nameless
|
|
{'files': [{'name': 'A.PAT', 'bytes': '1,024'}]}, # size not an int
|
|
{'files': 'A.PAT'}, # not a list
|
|
]
|
|
with bk_app.app_context():
|
|
for manifest in bad:
|
|
with pytest.raises(ValueError):
|
|
bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': 'b2' * 32, 'sharepath': r'\\host\s\x',
|
|
'manifest': manifest})
|
|
|
|
|
|
def test_buildinfo_kwargs_do_not_disturb_the_live_ntlars_card(bk_app, bk_plugin):
|
|
"""The endpoint passes revisionid and sourcehostname to EVERY kind.
|
|
|
|
NTLARS is live in production; a new keyword reaching its buildinfo must be
|
|
absorbed by **options rather than raising.
|
|
"""
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload(_payload())
|
|
_db.session.commit()
|
|
from plugins.backups.models import BackupRevision
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
# The assertion is that this does not raise TypeError on the new
|
|
# keywords, not that this particular fixture produces sections.
|
|
card = registry.getkind('ntlars').buildinfo(
|
|
revision.payload, revision.assetid,
|
|
partmarkertypes=None, revisionid=revision.backuprevisionid,
|
|
sourcehostname=revision.sourcehostname)
|
|
assert 'sectioncount' in card
|
|
|
|
|
|
def test_the_card_shows_the_snapshot_path_it_was_actually_written_to(bk_app, bk_plugin):
|
|
"""Plain text, and from the REVISION - not composed from the setting.
|
|
|
|
A snapshot written before the share layout changed still lives where it was
|
|
put, so the stored path is the only honest answer. It is not a link: a
|
|
browser refuses to navigate from https to file://, so a clickable path
|
|
would look actionable and do nothing.
|
|
"""
|
|
from plugins.backups.services import partmarkerinfo
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload({
|
|
'machinenumber': '3204', 'backupkind': 'partmarker',
|
|
'contenthash': 'c3' * 32,
|
|
'sharepath': r'\\host\share\partmarker\3204\20260825-144500',
|
|
'sourcehostname': 'FBYTNCX3',
|
|
'manifest': _markermanifest(['A.PAT'])})
|
|
_db.session.commit()
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
|
|
card = partmarkerinfo.build(revision.payload, revision.assetid,
|
|
revisionid=revision.backuprevisionid,
|
|
sharepath=revision.sharepath)
|
|
snapshot = [f for f in card['fields'] if f['label'] == 'Snapshot']
|
|
assert snapshot and snapshot[0]['value'] == revision.sharepath
|
|
assert snapshot[0]['mono'] is True
|
|
|
|
|
|
def test_the_card_omits_the_snapshot_row_when_no_path_is_passed(bk_app, bk_plugin):
|
|
from plugins.backups.services import partmarkerinfo
|
|
card = partmarkerinfo.build(_markermanifest(['A.PAT']), 1)
|
|
assert 'Snapshot' not in [f['label'] for f in card['fields']]
|
|
|
|
|
|
def test_an_empty_source_hostname_still_dedups(bk_app, bk_plugin):
|
|
"""A client posting sourcehostname='' must not defeat dedup forever.
|
|
|
|
samesource() maps '' to IS NULL for the QUERY, but the insert stored the raw
|
|
'' - so the rows a client wrote could never match its own next post. Every
|
|
post inserted, the table grew without bound, and the revision history filled
|
|
with identical entries nobody could read.
|
|
"""
|
|
from plugins.backups.models import BackupRevision
|
|
with bk_app.app_context():
|
|
first = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
|
|
assert first['action'] == 'created'
|
|
|
|
second = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
|
|
assert second['action'] == 'noop', 'an identical re-post must dedup'
|
|
|
|
rows = BackupRevision.query.all()
|
|
assert len(rows) == 1
|
|
assert rows[0].sourcehostname is None, \
|
|
"'' is stored as NULL, matching how samesource() queries it"
|
|
|
|
|
|
def test_a_padded_source_hostname_is_the_same_source(bk_app, bk_plugin):
|
|
"""Whitespace must not fork a machine's history into two chains."""
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload(sourcehostname='GGBX0NH3ESF'))
|
|
second = bk_plugin.apply_collector_payload(
|
|
_payload(sourcehostname=' GGBX0NH3ESF '))
|
|
assert second['action'] == 'noop'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Credential redaction. The DNC registry carries an FTP password in cleartext;
|
|
# stored verbatim it reached the database, the revision panel and the download
|
|
# route, so anyone with backups.view could read the fleet's password off a page.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# A configured install that also carries the FTP credential, which is what a
|
|
# real marker/DNC bay actually has in its registry.
|
|
SECRETREG = CONFIGUREDREG.replace(
|
|
'"MachineNo"="3204"',
|
|
'"MachineNo"="3204"\r\n"FtpPasswd"="hunter2"\r\n"FtpUser"="dnc"')
|
|
|
|
|
|
def test_a_credential_never_enters_the_projection():
|
|
"""Redaction happens at parse, so the secret is never persisted.
|
|
|
|
Redacting on display instead would leave it at rest and one query away.
|
|
"""
|
|
import json
|
|
from plugins.backups.services import ntlars
|
|
|
|
projection = ntlars.parse(_asbytes(SECRETREG))
|
|
|
|
assert 'hunter2' not in json.dumps(projection)
|
|
assert projection['redacted'] == ['General\\FtpPasswd']
|
|
values = dict(projection['keys'][0]['values'])
|
|
for key in projection['keys']:
|
|
values.update(key['values'])
|
|
assert values['FtpPasswd']['data'] == ntlars.REDACTEDMARKER
|
|
assert values['FtpUser']['data'] == 'dnc', 'only credentials are withheld'
|
|
|
|
|
|
def test_a_redacted_value_is_commented_out_not_imported():
|
|
"""Importing the marker as a password yields a machine that cannot log in.
|
|
|
|
It would LOOK restored, and the technician would go debugging the network.
|
|
A commented-out value fails at the one moment somebody is reading the file.
|
|
"""
|
|
from plugins.backups.services import ntlars
|
|
|
|
text = ntlars.render(ntlars.parse(_asbytes(SECRETREG))).decode('utf-16-le')
|
|
|
|
assert 'hunter2' not in text
|
|
assert ntlars.REDACTEDMARKER not in text
|
|
assert 'FtpPasswd' in text, 'the omission must be named, not silent'
|
|
assert '"FtpPasswd"="' not in text, 'must not be an importable value line'
|
|
assert '"FtpUser"="dnc"' in text, 'everything else still restores'
|
|
|
|
|
|
def test_the_download_route_cannot_hand_back_a_password(bk_app, bk_plugin):
|
|
"""End to end: post a config with a password, download it back."""
|
|
with bk_app.app_context():
|
|
result = bk_plugin.apply_collector_payload(_payload(reg=SECRETREG))
|
|
assert result['action'] == 'created'
|
|
|
|
from plugins.backups.models import BackupRevision
|
|
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
|
stored = json.dumps(revision.payload)
|
|
assert 'hunter2' not in stored
|
|
|
|
rendered = ntlars_render(revision.payload)
|
|
assert 'hunter2' not in rendered
|
|
|
|
|
|
def ntlars_render(projection):
|
|
from plugins.backups.services import ntlars
|
|
return ntlars.render(projection).decode('utf-16-le')
|
|
|
|
|
|
def test_secret_name_matching_covers_the_usual_spellings():
|
|
from plugins.backups.services.ntlars import issecretname
|
|
for name in ('FtpPasswd', 'Password', 'ftp_pwd', 'ApiKey', 'AuthToken',
|
|
'ClientSecret'):
|
|
assert issecretname(name), name
|
|
for name in ('FtpUser', 'FMSHost', 'Retries', 'Passive'):
|
|
assert not issecretname(name), name
|
|
|
|
|
|
# --- "current" is per source chain, not per kind -----------------------------
|
|
#
|
|
# NTLARS settings describe a MACHINE but are read off the controlling PC, so a
|
|
# machine with two reporting PCs has two independent chains of the same kind.
|
|
# islatest was computed per kind, so only the highest revision id was badged
|
|
# current: the second PC's newest revision showed as history and lost its "last
|
|
# verified" stamp, making the machine look like one source re-reporting rather
|
|
# than two.
|
|
|
|
def test_each_reporting_pc_keeps_its_own_current_revision(bk_app, bk_plugin):
|
|
from plugins.backups.api.routes import _summary
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
# Two PCs reporting the same machine, each twice.
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
changed = CONFIGUREDREG.replace('"Cnc"=""', '"Cnc"="OKUMA"')
|
|
bk_plugin.apply_collector_payload(_payload(reg=changed))
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(sourcehostname='NEWPC001'))
|
|
again = CONFIGUREDREG.replace('"Cnc"=""', '"Cnc"="MAZAK"')
|
|
bk_plugin.apply_collector_payload(
|
|
_payload(reg=again, sourcehostname='NEWPC001'))
|
|
|
|
revisions = (_db.session.query(BackupRevision)
|
|
.order_by(BackupRevision.backuprevisionid.desc()).all())
|
|
assert len(revisions) >= 4
|
|
|
|
# Mirror what the listing route does.
|
|
seen = set()
|
|
current = {}
|
|
for revision in revisions:
|
|
chain = (revision.backupkind,
|
|
(revision.sourcehostname or '').strip().lower())
|
|
if chain not in seen:
|
|
seen.add(chain)
|
|
current[revision.sourcehostname] = revision.backuprevisionid
|
|
|
|
assert set(current) == {'GGBX0NH3ESF', 'NEWPC001'}, \
|
|
'each reporting PC has a current revision of its own'
|
|
assert _summary(revisions[0], islatest=True)['islatest'] is True
|
|
|
|
|
|
# --- a diff has to be about ONE asset ---------------------------------------
|
|
#
|
|
# ?against=<id> accepted any revision id. Pointing it at another asset's
|
|
# revision rendered that asset's configuration values as the "before" side of
|
|
# this asset's history: a diff that reads as a change on this machine while
|
|
# describing a different one. The default (no ?against) has always been
|
|
# asset-scoped; only the explicit form was not.
|
|
|
|
def _bk_admin_headers(bk_app):
|
|
"""An admin in the BACKUPS app's own database.
|
|
|
|
The shared `client` fixture talks to the session app, which is a different
|
|
database entirely - a revision created through bk_plugin is invisible to
|
|
it, which is why a route test has to be driven from this app.
|
|
"""
|
|
from werkzeug.security import generate_password_hash
|
|
|
|
from shopdb.core.models import Permission, Role, User
|
|
|
|
with bk_app.app_context():
|
|
Permission.seed()
|
|
user = User.query.filter_by(username='bkadmin').first()
|
|
if user is None:
|
|
admin = Role.query.filter_by(rolename='admin').first()
|
|
if admin is None:
|
|
admin = Role(rolename='admin', description='Admin')
|
|
_db.session.add(admin)
|
|
_db.session.flush()
|
|
admin.permissions = Permission.query.all()
|
|
user = User(username='bkadmin', email='bkadmin@test.local',
|
|
passwordhash=generate_password_hash('bkpass123'),
|
|
isactive=True)
|
|
user.roles = [admin]
|
|
_db.session.add(user)
|
|
_db.session.commit()
|
|
|
|
client = bk_app.test_client()
|
|
login = client.post('/api/auth/login',
|
|
json={'username': 'bkadmin', 'password': 'bkpass123'})
|
|
assert login.status_code == 200, login.get_json()
|
|
token = login.get_json()['data']['access_token']
|
|
return client, {'Authorization': 'Bearer %s' % token}
|
|
|
|
|
|
def test_a_diff_against_another_assets_revision_is_refused(bk_app, bk_plugin):
|
|
from shopdb.core.models import Asset, AssetType
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
mine = (_db.session.query(BackupRevision)
|
|
.order_by(BackupRevision.backuprevisionid.desc()).first())
|
|
|
|
# A revision belonging to a different asset entirely.
|
|
assettype = AssetType.query.first() or AssetType(assettype='machine')
|
|
_db.session.add(assettype)
|
|
_db.session.flush()
|
|
other_asset = Asset(assetnumber='OTHER-ASSET-1',
|
|
assettypeid=assettype.assettypeid)
|
|
_db.session.add(other_asset)
|
|
_db.session.flush()
|
|
theirs = BackupRevision(assetid=other_asset.assetid,
|
|
backupkind=mine.backupkind,
|
|
storagebackend='shopdb',
|
|
contenthash='a' * 64)
|
|
theirs.payload = {'schema': ntlars.SCHEMA, 'keys': []}
|
|
_db.session.add(theirs)
|
|
_db.session.commit()
|
|
mineid, theirsid = mine.backuprevisionid, theirs.backuprevisionid
|
|
|
|
client, headers = _bk_admin_headers(bk_app)
|
|
refused = client.get('/api/backups/revisions/%d/diff?against=%d'
|
|
% (mineid, theirsid), headers=headers)
|
|
assert refused.status_code == 400, refused.get_json()
|
|
assert 'different asset' in refused.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_a_diff_against_a_sibling_revision_still_works(bk_app, bk_plugin):
|
|
"""Scoping it must not break the case the feature exists for."""
|
|
from plugins.backups.models import BackupRevision
|
|
|
|
with bk_app.app_context():
|
|
bk_plugin.apply_collector_payload(_payload())
|
|
changed = CONFIGUREDREG.replace('"Cnc"=""', '"Cnc"="OKUMA"')
|
|
bk_plugin.apply_collector_payload(_payload(reg=changed))
|
|
_db.session.commit()
|
|
rows = (_db.session.query(BackupRevision)
|
|
.order_by(BackupRevision.backuprevisionid.desc()).limit(2).all())
|
|
newer, older = rows[0].backuprevisionid, rows[1].backuprevisionid
|
|
|
|
client, headers = _bk_admin_headers(bk_app)
|
|
allowed = client.get('/api/backups/revisions/%d/diff?against=%d'
|
|
% (newer, older), headers=headers)
|
|
assert allowed.status_code == 200, allowed.get_json()
|