Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:15:03 -04:00
parent cf95daf3bb
commit fd513c08eb
112 changed files with 5182 additions and 857 deletions

View File

@@ -1525,3 +1525,142 @@ def test_secret_name_matching_covers_the_usual_spellings():
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()

View File

@@ -0,0 +1,104 @@
"""Changing how a PC is reached has to leave a record.
Editing a computer wrote an audit entry for its named fields and nothing at all
for two changes that matter most to anybody investigating later:
* the primary IP address - how the PC is found on the network
* the remote-access protocols and their ports - how somebody connects to it
Both are set on the same form, in the same request, and both were invisible
afterwards. A PC that moved address had no history of moving, and a PC that
gained a VNC listener had no record of gaining one.
"""
import pytest
from shopdb.api import db
@pytest.fixture
def pc(client, db, auth_headers):
from shopdb.core.models import AssetType, AssetStatus
if not AssetType.query.filter_by(assettype='computer').first():
db.session.add(AssetType(assettype='computer'))
if not AssetStatus.query.first():
db.session.add(AssetStatus(status='In Use'))
db.session.commit()
created = client.post('/api/computers', headers=auth_headers, json={
'assetnumber': 'AUDIT-PC-1',
'hostname': 'auditpc1',
'ipaddress': '10.20.30.40',
})
assert created.status_code == 201, created.get_json()
data = created.get_json()['data']
return data.get('computerid') or data['computer']['computerid']
def _entries(entityid):
from shopdb.core.models import AuditLog
return (AuditLog.query
.filter_by(entitytype='Computer', entityid=entityid)
.order_by(AuditLog.auditlogid.desc()).all())
def test_moving_a_pc_to_another_address_is_recorded(client, auth_headers, pc):
response = client.put('/api/computers/%d' % pc, headers=auth_headers,
json={'ipaddress': '10.20.30.99'})
assert response.status_code == 200, response.get_json()
latest = _entries(pc)[0]
assert 'ipaddress' in (latest.changes or {}), latest.changes
assert latest.changes['ipaddress']['old'] == '10.20.30.40'
assert latest.changes['ipaddress']['new'] == '10.20.30.99'
def test_an_unchanged_address_records_nothing(client, auth_headers, pc):
"""A PUT that re-sends the same value is not a change, and an audit log
full of non-events is one nobody reads."""
before = len(_entries(pc))
response = client.put('/api/computers/%d' % pc, headers=auth_headers,
json={'ipaddress': '10.20.30.40'})
assert response.status_code == 200
assert len(_entries(pc)) == before
def test_changing_the_remote_access_protocols_is_recorded(client, db,
auth_headers, pc):
from plugins.computers.models import AccessProtocol
rdp = AccessProtocol(name='RDP', scheme='rdp', linktemplate='rdp://{host}')
vnc = AccessProtocol(name='VNC', scheme='vnc', linktemplate='vnc://{host}')
db.session.add_all([rdp, vnc])
db.session.commit()
client.put('/api/computers/%d' % pc, headers=auth_headers,
json={'accessmethods': [{'protocolid': rdp.protocolid}]})
response = client.put('/api/computers/%d' % pc, headers=auth_headers,
json={'accessmethods': [
{'protocolid': vnc.protocolid,
'portoverride': 5999}]})
assert response.status_code == 200, response.get_json()
latest = _entries(pc)[0]
assert 'accessmethods' in (latest.changes or {}), latest.changes
change = latest.changes['accessmethods']
assert change['old'] == ['%d:default' % rdp.protocolid]
assert change['new'] == ['%d:5999' % vnc.protocolid]
def test_resending_the_same_protocols_records_nothing(client, db,
auth_headers, pc):
from plugins.computers.models import AccessProtocol
ssh = AccessProtocol(name='SSH', scheme='ssh', linktemplate='ssh://{host}')
db.session.add(ssh)
db.session.commit()
client.put('/api/computers/%d' % pc, headers=auth_headers,
json={'accessmethods': [{'protocolid': ssh.protocolid}]})
before = len(_entries(pc))
client.put('/api/computers/%d' % pc, headers=auth_headers,
json={'accessmethods': [{'protocolid': ssh.protocolid}]})
assert len(_entries(pc)) == before

View File

@@ -94,9 +94,16 @@ def test_dashboard_machine_count_collapses(client, db, auth_headers):
assert counts['counts']['machines'] == 1
def test_map_excludes_secondary_marker(client, db, auth_headers):
def test_an_unpositioned_secondary_is_represented_by_the_primary(
client, db, auth_headers):
"""The common case: the pair sits in one place, so it draws one marker and
that marker is labelled with both bays."""
invalidate_settings_cache()
_seed_dualpath_pair()
secondary = Asset.query.filter_by(assetnumber='2008').first()
secondary.mapx = None
secondary.mapy = None
_db.session.commit()
resp = client.get('/api/assets/map', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
@@ -106,7 +113,30 @@ def test_map_excludes_secondary_marker(client, db, auth_headers):
assert '2007' in numbers
assert '2008' not in numbers
primary = next(a for a in assets if a['assetnumber'] == '2007')
assert primary['dualpathpartner'] == '2008'
assert primary['dualpathpartner'] == '2008', \
'the one marker has to say it stands for both bays'
def test_a_positioned_secondary_draws_its_own_marker(client, db, auth_headers):
"""A second bay that is physically somewhere else. Positioning it used to
be a silent no-op: the form took the pin, stored it, and the map never drew
it, because the secondary was excluded from the feed unconditionally."""
invalidate_settings_cache()
_seed_dualpath_pair() # fixture positions BOTH bays
resp = client.get('/api/assets/map', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
assets = resp.get_json()['data']['assets']
numbers = [a['assetnumber'] for a in assets]
assert '2007' in numbers
assert '2008' in numbers, 'a positioned secondary bay draws its own marker'
# Two pins, two names. Labelling both '2007 / 2008' would say the same
# machine is in two places.
for assetnumber in ('2007', '2008'):
marker = next(a for a in assets if a['assetnumber'] == assetnumber)
assert marker['dualpathpartner'] is None, assetnumber
def test_detail_banner_partner_shown_regardless_of_toggle(client, db, auth_headers):
@@ -161,3 +191,86 @@ def test_map_shows_both_bays_when_disabled(client, db, auth_headers):
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
assert '2007' in numbers
assert '2008' in numbers
def _give_both_bays_a_status():
"""The pair fixture leaves statusid null, so the bystatus join matches
nothing. Give both bays the same status, which is what a real site has and
what makes the breakdown meaningful."""
from shopdb.core.models import AssetStatus
status = AssetStatus(status='In Use')
_db.session.add(status)
_db.session.flush()
for assetnumber in ('2007', '2008'):
asset = Asset.query.filter_by(assetnumber=assetnumber).first()
asset.statusid = status.statusid
_db.session.commit()
def test_the_status_breakdown_adds_up_to_the_total(client, db, auth_headers):
"""The dualpath exclusion was applied to total and bytype but not to
bystatus, so the status bars summed to more than the headline they sit
under - by exactly the number of secondary bays. A breakdown that does not
add up to its own total is an afternoon of reconciling."""
invalidate_settings_cache()
_seed_dualpath_pair()
_give_both_bays_a_status()
resp = client.get('/api/machines/dashboard/summary', headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
summary = resp.get_json()['data']
assert summary['total'] == 1, 'two bays, one physical machine'
assert sum(row['count'] for row in summary['bystatus']) == summary['total']
def test_the_status_breakdown_counts_both_bays_when_collapse_is_off(
client, db, auth_headers):
"""With the setting off, every bay is its own machine everywhere - the fix
must not start hiding rows a site asked to see."""
invalidate_settings_cache()
_seed_dualpath_pair()
_give_both_bays_a_status()
_set_toggle('false')
summary = client.get('/api/machines/dashboard/summary',
headers=auth_headers).get_json()['data']
assert summary['total'] == 2
assert sum(row['count'] for row in summary['bystatus']) == 2
def test_a_maintenance_date_is_served_as_a_plain_date(client, db, auth_headers):
"""It was a DATETIME holding midnight, serialised as '...T00:00:00Z', so a
browser west of UTC rendered every maintenance date one day early. A date
has no time and no timezone."""
from plugins.machines.models import Machine
# The create route resolves the 'machine' AssetType, which this module's
# fixture only seeds as part of building a pair.
if not AssetType.query.filter_by(assettype='machine').first():
_db.session.add(AssetType(assettype='machine'))
_db.session.commit()
created = client.post('/api/machines', headers=auth_headers, json={
'assetnumber': 'MAINT-DATE-1',
'name': 'Maintenance date probe',
'lastmaintenancedate': '2026-01-15',
'nextmaintenancedate': '2026-12-15',
})
assert created.status_code == 201, created.get_json()
payload = created.get_json()['data']
machineid = payload.get('machineid') or payload['machine']['machineid']
fetched = client.get('/api/machines/%d' % machineid,
headers=auth_headers).get_json()['data']
extension = fetched['machine']
assert extension['lastmaintenancedate'] == '2026-01-15'
assert extension['nextmaintenancedate'] == '2026-12-15'
for value in (extension['lastmaintenancedate'],
extension['nextmaintenancedate']):
assert 'T' not in value and 'Z' not in value, \
'a date must not carry a time or a timezone marker'
stored = db.session.get(Machine, machineid)
assert str(stored.lastmaintenancedate) == '2026-01-15'

View File

@@ -272,3 +272,66 @@ def test_fleet_list_route_resolves_without_trailing_slash():
endpoint_slash, _ = adapter.match('/api/endpoints/', method='GET')
assert endpoint_slash.endswith('list_endpoints')
# --- agent timestamps are UTC on the wire and UTC in the column --------------
#
# _parse_dt used astimezone(tz=None), which converts to the SERVER's local
# zone. An agent reporting 17:26:16Z was stored as 13:26:16 on an EDT server
# and re-served labelled 'Z', while the same row's server-written
# lastreportedat stayed true UTC - one row, two time bases, four hours apart.
# The dashboard's 48-hour stability window compares a real UTC cutoff against
# these values, so events left it four hours early.
#
# It is invisible on a UTC server, which is how it survived, and nothing in the
# Dockerfile, the compose files or the Windows deploy pins TZ.
def _in_zone(zonename, call):
"""Run call() with the process in a named zone, then put it back."""
import os
import time
before = os.environ.get('TZ')
os.environ['TZ'] = zonename
time.tzset()
try:
return call()
finally:
if before is None:
os.environ.pop('TZ', None)
else:
os.environ['TZ'] = before
time.tzset()
def test_a_utc_timestamp_survives_a_non_utc_server():
from datetime import datetime
from plugins.endpoints.plugin import _parse_dt
parsed = _in_zone('America/New_York',
lambda: _parse_dt('2026-09-03T17:26:16Z'))
assert parsed == datetime(2026, 9, 3, 17, 26, 16), \
'the wire value is UTC and the column is UTC; no local conversion'
def test_an_offset_timestamp_is_converted_to_utc_not_to_server_local():
from datetime import datetime
from plugins.endpoints.plugin import _parse_dt
parsed = _in_zone('America/New_York',
lambda: _parse_dt('2026-09-03T17:26:16+05:00'))
assert parsed == datetime(2026, 9, 3, 12, 26, 16)
def test_a_naive_timestamp_is_taken_as_utc_already():
"""AGENT-CONTRACT.md mandates UTC on the wire, so a value with no offset is
UTC rather than a guess about the agent's zone."""
from datetime import datetime
from plugins.endpoints.plugin import _parse_dt
parsed = _in_zone('Asia/Tokyo',
lambda: _parse_dt('2026-09-03T17:26:16'))
assert parsed == datetime(2026, 9, 3, 17, 26, 16)

View File

@@ -63,3 +63,61 @@ def test_export_backs_up_existing(client, db, app, auth_headers, tmp_path):
historydir = tmp_path / '_meta' / 'history'
assert historydir.is_dir()
assert any(historydir.iterdir())
# --- a scope name is a directory on the share --------------------------------
#
# create_scope checked only that the name was non-empty, and the export did
# os.path.join(shareroot, scopename, 'manifest.json'). So "../escape" wrote one
# level above the share root and "/tmp/anywhere" wrote wherever it liked. The
# exposure is the permission split: geenforce.manage creates the scope,
# geenforce.publish writes the file, so a manage-only author could aim a
# publisher's write at any path that process can reach.
import pytest
@pytest.mark.parametrize('name', [
'../escape',
'../../escape',
'/tmp/absolute',
'sub/dir',
'back\\slash',
'..',
'.',
])
def test_a_traversing_scope_name_is_refused_at_creation(client, db,
auth_headers, name):
created = client.post('/api/geenforce/scopes',
json={'scopename': name, 'phase': 'runtime'},
headers=auth_headers)
assert created.status_code == 400, (name, created.get_json())
assert 'scopename' in created.get_json()['data']['error']['message']
def test_a_normal_scope_name_is_still_accepted(client, db, auth_headers):
"""The names sites actually use must keep working, or the guard is worse
than the hole."""
for name in ('common', 'display', 'gea-shopfloor-cmm', 'bay.3105_a'):
created = client.post('/api/geenforce/scopes',
json={'scopename': name, 'phase': 'runtime'},
headers=auth_headers)
assert created.status_code in (200, 201), (name, created.get_json())
def test_the_export_refuses_a_traversing_name_even_from_the_database(
app, db, tmp_path):
"""Rows created before the validation existed are still out there, and the
export is the line that actually writes."""
with app.app_context():
service.replace_scope_draft('../legacy-escape', 'runtime',
{'Version': '2.6', 'Applications': []})
service.publish_scope('../legacy-escape', 'runtime', notes='v1')
service.db.session.commit()
with pytest.raises(ValueError) as refused:
service.export_scope_to_share('../legacy-escape', 'runtime',
str(tmp_path))
assert 'safe directory name' in str(refused.value)
assert not (tmp_path.parent / 'legacy-escape').exists()

View File

@@ -576,3 +576,24 @@ def test_a_section_counts_as_something_to_tick():
]
# heading + step + two checklist items
assert checklistitemcount(blocks) == 4
def test_restoring_the_current_version_says_nothing_changed(gd_app, gd_clean):
"""save() dedups on the content hash, so restoring the version that is
already current writes nothing. Reporting "Restored version N as a new
draft version" described a revision that did not exist, and the unchanged
revision list quietly disagreed with the message."""
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
_, first = authoring.save(guide, title='T',
blocks=[_block('b1', 'paragraph', text='only')])
_db.session.commit()
before = guide.revision
authoring.restore(guide, first)
_db.session.commit()
assert guide.revision == before, 'nothing new was written'
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1

View File

@@ -488,3 +488,159 @@ def test_asset_presentation_route_token():
entries = MeasuringToolsPlugin().get_asset_presentation()
entry = next(e for e in entries if e['assettype'] == 'measuring_tool')
assert entry['route'] == '/measuringtools/by-asset/{assetid}'
# --- a date that is not a date -----------------------------------------------
#
# _parse_date used to swallow the ValueError and return None, so a typo in the
# calibration date answered 200 "Measuring tool updated", stored NULL, flipped
# the tool to 'unknown', and dropped it out of the calibration report and the
# map overlay. Clearing a date and mistyping one are different intents and now
# get different answers.
def test_a_mistyped_calibration_date_is_refused_not_stored_as_null(
client, auth_headers):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DATE-1',
'name': 'Date guard caliper',
'statusid': _status_id(client),
'nextcalibrationdate': '2027-01-01',
})
assert created.status_code == 201, created.get_json()
toolid = created.get_json()['data']['measuringtool']['measuringtoolid']
refused = client.put(f'/api/measuringtools/{toolid}', headers=auth_headers,
json={'nextcalibrationdate': 'not-a-date'})
assert refused.status_code == 400, refused.get_json()
assert 'nextcalibrationdate' in \
refused.get_json()['data']['error']['message']
# The stored date is untouched - the whole point of refusing.
after = client.get(f'/api/measuringtools/{toolid}',
headers=auth_headers).get_json()['data']
assert after['measuringtool']['nextcalibrationdate'] == '2027-01-01'
assert after['measuringtool']['calibrationstatus'] != 'unknown'
def test_an_explicitly_empty_calibration_date_still_clears_it(client,
auth_headers):
"""Clearing is a real intent and must keep working, or the fix above just
trades one broken behaviour for another."""
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DATE-2',
'name': 'Clearable caliper',
'statusid': _status_id(client),
'nextcalibrationdate': '2027-01-01',
})
toolid = created.get_json()['data']['measuringtool']['measuringtoolid']
cleared = client.put(f'/api/measuringtools/{toolid}', headers=auth_headers,
json={'nextcalibrationdate': ''})
assert cleared.status_code == 200, cleared.get_json()
after = client.get(f'/api/measuringtools/{toolid}',
headers=auth_headers).get_json()['data']
assert after['measuringtool']['nextcalibrationdate'] is None
def test_a_mistyped_date_is_refused_at_create_too(client, auth_headers):
refused = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-DATE-3',
'name': 'Never created',
'statusid': _status_id(client),
'lastcalibrationdate': '01/02/2026',
})
assert refused.status_code == 400, refused.get_json()
listed = client.get('/api/measuringtools?search=MT-DATE-3',
headers=auth_headers).get_json()['data']
assert listed == [], 'nothing may be created by a refused request'
# --- an audited field holding a date must not kill the request ---------------
#
# PUT /api/machines/<id> returned 500 and rolled the whole edit back for any
# machine with a maintenance date: the route builds
# {'old': getattr(row, field)} straight off the model, and json.dumps raises on
# a date. A rename that never touched the date failed too. The audit log
# refusing to record a change must not be what stops the change.
def test_an_edit_is_saved_even_when_an_audited_field_holds_a_date(
client, auth_headers):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-AUDIT-1',
'name': 'Before rename',
'statusid': _status_id(client),
'lastcalibrationdate': '2026-01-15',
'nextcalibrationdate': '2027-01-15',
})
assert created.status_code == 201, created.get_json()
toolid = created.get_json()['data']['measuringtool']['measuringtoolid']
# A rename that does not touch the dates. The date is only in the CHANGES
# dict because the interval moves with it.
renamed = client.put(f'/api/measuringtools/{toolid}', headers=auth_headers,
json={'name': 'After rename',
'nextcalibrationdate': '2028-01-15'})
assert renamed.status_code == 200, renamed.get_json()
after = client.get(f'/api/measuringtools/{toolid}',
headers=auth_headers).get_json()['data']
assert after['name'] == 'After rename'
assert after['measuringtool']['nextcalibrationdate'] == '2028-01-15'
# --- a derived filter must page over the MATCHES ----------------------------
#
# calibrationstatus was applied to the rows of the current page, so a request
# for 'overdue' returned whatever matched on that page while the total counted
# every tool. With one overdue tool in sixteen: page 1 empty, total 16, eight
# pages, and the row itself on page 3. The filter hid matching rows and
# miscounted the rest.
def test_a_calibration_filter_pages_over_the_matches_not_the_page(
client, auth_headers):
from datetime import date, timedelta
overdue = (date.today() - timedelta(days=5)).isoformat()
current = (date.today() + timedelta(days=400)).isoformat()
# One overdue tool, well past the first page of a small page size.
for index in range(6):
created = client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-PAGE-%02d' % index,
'name': 'Paged tool %d' % index,
'statusid': _status_id(client),
'nextcalibrationdate': overdue if index == 5 else current,
})
assert created.status_code == 201, created.get_json()
# Scoped with search as well, so earlier tests in this module cannot
# change the expected set - the point is paging over matches, not how many
# overdue tools the whole fixture happens to hold.
response = client.get(
'/api/measuringtools?calibrationstatus=overdue&search=MT-PAGE&perpage=2',
headers=auth_headers)
assert response.status_code == 200, response.get_json()
body = response.get_json()
rows = body['data']
pagination = body['meta']['pagination']
assert [row['assetnumber'] for row in rows] == ['MT-PAGE-05'], \
'the overdue tool must be on page 1 of a filtered list'
assert pagination['total'] == 1, \
'the total counts the MATCHES, not every tool'
assert pagination['totalpages'] == 1
def test_an_unfiltered_list_still_pages_normally(client, auth_headers):
for index in range(3):
client.post('/api/measuringtools', headers=auth_headers, json={
'assetnumber': 'MT-PLAIN-%02d' % index,
'name': 'Plain tool %d' % index,
'statusid': _status_id(client),
})
response = client.get('/api/measuringtools?perpage=2', headers=auth_headers)
assert response.status_code == 200
pagination = response.get_json()['meta']['pagination']
assert pagination['perpage'] == 2
assert pagination['total'] >= 3

View File

@@ -0,0 +1,80 @@
"""Network reference data has to survive being typed at.
Three findings from the 2026-09-03 run, all in the same family: a validation
that looked like one and was not.
* cidr was checked for a '/' and nothing else, so 'notanip/xx' was stored.
_devices_in_subnet then swallowed the ValueError from ip_network and
returned an empty list, so the subnet looked real and permanently empty.
* vlannumber had no range check, so 99999 and -5 were accepted - and a
non-numeric value was worse, because the duplicate probe compared a string
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.
* deleting a VLAN that subnets still reference answered 400, while every
sibling in-use guard in the product answers 409.
"""
import pytest
def _vlan(client, headers, number, name='Test VLAN'):
return client.post('/api/network/vlans', headers=headers,
json={'vlannumber': number, 'name': name})
@pytest.mark.parametrize('cidr', [
'notanip/xx',
'10.1.1.0/33',
'hello/24',
'10.1.1.0',
'/24',
])
def test_a_subnet_refuses_something_that_is_not_a_network(client, db,
auth_headers, cidr):
response = client.post('/api/network/subnets', headers=auth_headers,
json={'cidr': cidr, 'name': 'Bad subnet'})
assert response.status_code == 400, (cidr, response.get_json())
def test_a_real_subnet_is_accepted_and_normalised(client, db, auth_headers):
"""A host address with a prefix is the same network; storing both spellings
would make two rows nobody can tell apart."""
response = client.post('/api/network/subnets', headers=auth_headers,
json={'cidr': '10.1.1.5/24', 'name': 'Real subnet'})
assert response.status_code in (200, 201), response.get_json()
assert response.get_json()['data']['cidr'] == '10.1.1.0/24'
@pytest.mark.parametrize('number', [0, 4095, 99999, -5, 'xyz'])
def test_a_vlan_number_outside_the_standard_is_refused(client, db,
auth_headers, number):
response = _vlan(client, auth_headers, number)
assert response.status_code == 400, (number, response.get_json())
@pytest.mark.parametrize('number', [1, 100, 4094])
def test_a_valid_vlan_number_is_accepted(client, db, auth_headers, number):
response = _vlan(client, auth_headers, number, name='VLAN %s' % number)
assert response.status_code in (200, 201), response.get_json()
def test_a_duplicate_vlan_names_the_number_it_actually_clashed_with(
client, db, auth_headers):
assert _vlan(client, auth_headers, 42, name='First').status_code in (200, 201)
clash = _vlan(client, auth_headers, 42, name='Second')
assert clash.status_code == 409
assert '42' in clash.get_json()['data']['error']['message']
def test_deleting_a_vlan_in_use_is_a_conflict_like_every_other_guard(
client, db, auth_headers):
created = _vlan(client, auth_headers, 77, name='In use')
vlanid = created.get_json()['data']['vlanid']
subnet = client.post('/api/network/subnets', headers=auth_headers,
json={'cidr': '10.7.7.0/24', 'name': 'Attached',
'vlanid': vlanid})
assert subnet.status_code in (200, 201), subnet.get_json()
refused = client.delete('/api/network/vlans/%d' % vlanid,
headers=auth_headers)
assert refused.status_code == 409, refused.get_json()
assert refused.get_json()['data']['error']['code'] == 'CONFLICT'

View File

@@ -532,3 +532,123 @@ def test_the_badge_route_is_not_an_enumeration_oracle(client, item, auth_headers
assert unknown.status_code == malformed.status_code == 422
assert (unknown.get_json()['data']['error']['message']
== malformed.get_json()['data']['error']['message'])
# --- the race ---------------------------------------------------------------
#
# The take read quantityonhand, compared it in Python, and then wrote. Five
# concurrent takes of 5 from a bin holding 5 all passed that comparison and all
# wrote their own 5 - 5 = 0, so quantityonhand ended up permanently out of step
# with the ledger that had recorded every one of them. The comparison now lives
# in the UPDATE's WHERE clause, where it is atomic.
def test_a_take_that_passed_its_precheck_is_still_refused_by_the_row(
client, auth_headers, app, item, directory_employee, kiosk,
monkeypatch):
"""Simulate the race window: the request holds an item that says 5 on hand
while the row has already been emptied by somebody else."""
from plugins.printedparts.api import routes
from plugins.printedparts.models import PrintedItem
stocked = client.post(f'/api/printedparts/items/{item}/restock',
json={'quantity': 5, 'badge': directory_employee},
headers=auth_headers)
assert stocked.status_code == 200
real_find = routes._kiosk_find_item
def stale_find(itemcode):
"""What the losing request sees: its own read, taken before the winner
committed, still shows a full bin.
Detached first, so setting the attribute changes only this request's
view and never reaches the database - the test is about a stale
reader, not about a second writer.
"""
found = real_find(itemcode)
if found is not None:
db.session.expunge(found)
found.quantityonhand = 5
return found
# Somebody else empties the bin first, through the kiosk, so the ledger
# records it the way a real competing take would.
winner = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001', 'quantity': 5,
'badge': directory_employee},
headers=KIOSK_HEADERS)
assert winner.status_code == 200, winner.get_json()
monkeypatch.setattr(routes, '_kiosk_find_item', stale_find)
taken = client.post('/api/printedparts/kiosk/take',
json={'itemcode': '3DP-9001', 'quantity': 5,
'badge': directory_employee},
headers=KIOSK_HEADERS)
assert taken.status_code == 400, taken.get_json()
assert 'on hand' in taken.get_json()['data']['error']['message']
with app.app_context():
after = db.session.query(PrintedItem).filter_by(
printeditemid=item).first()
assert after.quantityonhand == 0, 'stock must not go negative'
ledger = sum(t.quantitychange for t in after.transactions)
assert ledger == after.quantityonhand, \
'quantityonhand and the ledger must agree'
# --- the number on the bin is the number you type ---------------------------
#
# Keypad entry matched the digits ANYWHERE in the code, and the shipped prefix
# contains one: '3DP0004' collapsed to 30004, so an operator typing the 4
# printed on the label was told no part matches that barcode, and had to work
# out that the leading 3 belonged to the prefix.
def test_the_keypad_matches_the_number_printed_on_the_label(client, db,
auth_headers,
kiosk,
directory_employee):
from plugins.printedparts.models import PrintedItem
created = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Keypad clip'})
assert created.status_code == 201, created.get_json()
itemcode = created.get_json()['data']['itemcode']
assert itemcode.startswith('3DP'), itemcode
number = str(int(itemcode[3:]))
found = client.get('/api/printedparts/kiosk/item/%s' % number,
headers=KIOSK_HEADERS)
assert found.status_code == 200, (number, found.get_json())
assert found.get_json()['data']['itemcode'] == itemcode
def test_the_full_code_still_works(client, db, auth_headers, kiosk):
created = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Full code clip'})
itemcode = created.get_json()['data']['itemcode']
found = client.get('/api/printedparts/kiosk/item/%s' % itemcode,
headers=KIOSK_HEADERS)
assert found.status_code == 200, found.get_json()
def test_an_ambiguous_number_is_still_refused(client, db, auth_headers, kiosk):
"""Two items whose trailing numbers agree must not resolve to a guess -
taking from the wrong bin is worse than being told to scan the label."""
from plugins.printedparts.models import PrintedItem
first = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Ambiguous one'}).get_json()['data']
item = db.session.get(PrintedItem, first['printeditemid'])
item.gagelabtag = 'WJRP%04d' % 7777
db.session.commit()
second = client.post('/api/printedparts/items', headers=auth_headers,
json={'itemname': 'Ambiguous two'}).get_json()['data']
other = db.session.get(PrintedItem, second['printeditemid'])
other.gagelabtag = 'GAGE%04d' % 7777
db.session.commit()
refused = client.get('/api/printedparts/kiosk/item/7777',
headers=KIOSK_HEADERS)
assert refused.status_code == 404, refused.get_json()

View File

@@ -118,3 +118,60 @@ def test_a_row_that_names_a_vendor_is_not_matched_by_its_text(db, fleet):
printer = Printer(vendorid=brother.vendorid, modelnumberid=None)
printer.vendor = brother
assert _printer_driver(printer, _universal()) is None
# --- the two fields the converger actually needs -----------------------------
#
# drivername is the exact string the INF declares, and it is what
# Add-PrinterDriver matches on - `name` is ours to choose and means nothing to
# Windows. vendorid attaches a universal driver (HP UPD, Xerox GPD) to a whole
# make. Neither was read from the request body, so both came back null on a
# 201, and there was no interface that could set them: the shipped converger
# had nothing to match and installed nothing, and driver-name drift could never
# be detected because the expected value was never stored.
def test_a_driver_keeps_the_inf_name_and_vendor_it_was_given(client, db,
auth_headers):
from plugins.printers.models import PrinterDriver
created = client.post('/api/printers/drivers', headers=auth_headers, json={
'name': 'HP universal',
'location': r'\\server\share\hpupd',
'drivername': 'HP Universal Printing PCL 6',
'vendorid': 1,
})
assert created.status_code == 201, created.get_json()
body = created.get_json()['data']
assert body['drivername'] == 'HP Universal Printing PCL 6'
assert body['vendorid'] == 1
stored = db.session.get(PrinterDriver, body['driverid'])
assert stored.drivername == 'HP Universal Printing PCL 6'
assert stored.vendorid == 1
def test_the_inf_name_and_vendor_can_be_changed_afterwards(client, db,
auth_headers):
created = client.post('/api/printers/drivers', headers=auth_headers, json={
'name': 'Xerox universal', 'location': r'\\server\share\xerox'})
driverid = created.get_json()['data']['driverid']
assert created.get_json()['data']['drivername'] is None
updated = client.put('/api/printers/drivers/%d' % driverid,
headers=auth_headers,
json={'drivername': 'Xerox Global Print Driver PCL6',
'vendorid': 2})
assert updated.status_code == 200, updated.get_json()
assert updated.get_json()['data']['drivername'] == \
'Xerox Global Print Driver PCL6'
assert updated.get_json()['data']['vendorid'] == 2
def test_a_blank_inf_name_clears_it_rather_than_storing_whitespace(
client, db, auth_headers):
"""An empty box means "not known", which the converger reads as "cannot
install this". A whitespace string would look set and match nothing."""
created = client.post('/api/printers/drivers', headers=auth_headers, json={
'name': 'Blank test', 'location': r'\\server\share\blank',
'drivername': ' '})
assert created.get_json()['data']['drivername'] is None

View File

@@ -314,3 +314,126 @@ def test_the_token_alone_is_not_enough_when_the_kiosk_is_off(client, kiosk):
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1',
headers=KIOSK_HEADERS).status_code == 404
# --- the race, and who returned it ------------------------------------------
#
# The kiosk decided "is this drive available?" in Python and then wrote. Two
# scans of the same drive that interleaved both read "available", so both
# inserted a custody row: one drive recorded out to two people, and the
# documented 409 never fired. The fix puts ischeckedout in the WHERE clause, so
# the database settles it at write time.
def test_a_checkout_that_passed_its_availability_check_is_still_refused(
client, kiosk, monkeypatch):
"""Simulate the race window: the request holds a device object that says
available while the row has already been claimed by somebody else."""
from plugins.usb.api import selfhosted
from plugins.usb.models import USBDevice
first = _out(client, badge='270015376')
assert first.status_code == 200
# What the losing request sees: its own read, taken before the winner
# committed, still says the drive is on the shelf.
stale = USBDevice.query.filter_by(serialnumber='USB-1').first()
monkeypatch.setattr(
selfhosted, '_find',
lambda device_id: _stale_copy(stale))
second = _out(client, badge='270099999')
assert second.status_code == 409, second.get_json()
open_rows = _open_checkouts(USBDevice)
assert len(open_rows) == 1, 'one drive, one open custody row'
assert open_rows[0].sso == '270015376'
def _stale_copy(device):
"""A detached stand-in that still believes the drive is available."""
class Stale:
usbdeviceid = device.usbdeviceid
serialnumber = device.serialnumber
isactive = True
ischeckedout = False
currentuserid = None
currentusername = None
return Stale()
def _open_checkouts(_):
from plugins.usb.models import USBCheckout
return USBCheckout.query.filter_by(checkintime=None).all()
def test_a_return_records_who_handed_it_back_not_only_the_holder(client, kiosk):
"""Returning for a colleague is a supported flow, so the check-in has to
name the person who actually walked it back."""
from plugins.usb.models import USBCheckout
assert _out(client, badge='270015376').status_code == 200
returned = _in(client, badge='270099999', forsomeoneelse=True)
assert returned.status_code == 200, returned.get_json()
row = USBCheckout.query.order_by(USBCheckout.checkoutid.desc()).first()
assert row.sso == '270015376', 'the holder still owns the checkout'
assert row.checkinsso == '270099999', 'the returner is recorded'
assert row.checkinname and 'Sam' in row.checkinname
def test_a_check_in_closes_every_open_row_for_that_device(client, kiosk, db):
"""Defence in depth for history written before the guard existed: a device
can only be out once, so a second open row is corruption, and closing only
the newest left the older ones open forever."""
from plugins.usb.models import USBCheckout, USBDevice
assert _out(client, badge='270015376').status_code == 200
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso='270099999',
checkoutname='Sam Okafor'))
db.session.commit()
assert len(_open_checkouts(USBDevice)) == 2
assert _in(client, badge='270015376').status_code == 200
assert _open_checkouts(USBDevice) == []
# --- one badge vocabulary for the whole site --------------------------------
#
# The parts kiosk understood both shapes a scanner emits; this one took the
# string raw and compared it to an SSO. So an operator whose keyboard-wedge
# scanner emits the PayNo wrapper could take a 3D-printed part and could not
# check out a drive, with the same badge, at two cabinets a few metres apart.
#
# The same change closed a subtler hole: str.isdigit() is true for Arabic-Indic
# digits, so a badge written in them resolved through int() to a REAL employee
# and was then stored as an SSO string that joins to nothing.
def test_the_payno_wrapper_works_at_this_kiosk_too(client, kiosk):
from plugins.usb.models import USBCheckout
response = _out(client, badge='0270015376BZ')
assert response.status_code == 200, response.get_json()
row = USBCheckout.query.order_by(USBCheckout.checkoutid.desc()).first()
assert row.sso == '270015376', \
'the custody row records the SSO, not the wrapper it arrived in'
assert 'Dana' in (row.checkoutname or '')
def test_a_badge_in_non_ascii_digits_is_refused(client, kiosk):
"""It looks like a number to str.isdigit() and parses through int(), so it
resolved to a real employee under an SSO nothing else matches."""
# Written as escapes so this file stays ASCII, which the style gate
# enforces: these are Arabic-Indic digits, U+0669 and friends.
arabic_indic = '\u0669\u0667\u0660\u0660\u0660\u0667\u0661'
response = _out(client, badge=arabic_indic)
assert response.status_code == 422, response.get_json()
def test_the_same_person_scanning_either_shape_is_one_holder(client, kiosk):
"""Wrapper out, bare number back: the check-in has to recognise it as the
same person, or the drive can never be returned by its holder."""
assert _out(client, badge='0270015376BZ').status_code == 200
returned = _in(client, badge='270015376')
assert returned.status_code == 200, returned.get_json()

View File

@@ -108,3 +108,48 @@ def test_removing_clears_both_columns(client, db, auth_headers):
body = response.get_json()['data']
assert body['proofurl'] is None
assert body['prooffilename'] is None
# --- a date that is not a date -----------------------------------------------
#
# PUT /api/warranty/<id> with an unparseable enddate answered 200 "Warranty
# updated" and stored NULL, which drops the record out of every expiry surface
# it feeds. The local _parse_date swallowed the ValueError, so a typo and a
# deliberate clear produced the same write.
def _warranty_via_api(client, auth_headers, **fields):
payload = {'vendor': 'Acme', 'servicetag': 'WD-1', 'enddate': '2027-06-30'}
payload.update(fields)
created = client.post('/api/warranty', headers=auth_headers, json=payload)
assert created.status_code == 201, created.get_json()
return created.get_json()['data']['warrantyid']
def test_a_mistyped_end_date_is_refused_not_stored_as_null(client, db,
auth_headers):
warrantyid = _warranty_via_api(client, auth_headers)
refused = client.put(f'/api/warranty/{warrantyid}', headers=auth_headers,
json={'enddate': '30-06-2027'})
assert refused.status_code == 400, refused.get_json()
assert 'enddate' in refused.get_json()['data']['error']['message']
kept = db.session.get(Warranty, warrantyid)
assert kept.enddate is not None, 'the stored cover date must survive a typo'
assert str(kept.enddate) == '2027-06-30'
def test_an_explicitly_empty_end_date_still_clears_it(client, db, auth_headers):
warrantyid = _warranty_via_api(client, auth_headers, servicetag='WD-2')
cleared = client.put(f'/api/warranty/{warrantyid}', headers=auth_headers,
json={'enddate': ''})
assert cleared.status_code == 200, cleared.get_json()
assert db.session.get(Warranty, warrantyid).enddate is None
def test_a_mistyped_date_is_refused_at_create_too(client, db, auth_headers):
refused = client.post('/api/warranty', headers=auth_headers,
json={'vendor': 'Acme', 'servicetag': 'WD-3',
'startdate': 'yesterday'})
assert refused.status_code == 400, refused.get_json()
assert Warranty.query.filter_by(servicetag='WD-3').first() is None