Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:14:53 -04:00
parent ddf41c3e65
commit ff9ae693a6
112 changed files with 5182 additions and 857 deletions

View File

@@ -417,3 +417,45 @@ def test_update_scopes_ceiling_enforced(client, db):
headers=headers)
assert response.status_code == 400
assert 'applications.delete' in response.get_json()['data']['error']['message']
# --- a route that does its own permission check --------------------------
#
# require_permission suspends the admin bypass for a scoped token, and the
# tests above pin that. What they could not see is a route that checks a
# SECOND permission by hand: the endpoints command route asked
# current_user.haspermission(elevated), which reads the OWNER. A token scoped
# to endpoints.command alone could therefore queue system_restart, run_script
# and update_app on a shop-floor PC - the exact bypass this file exists to
# prevent, reintroduced one route at a time.
#
# The fix is in caller_holds, so this tests the helper rather than only the one
# route: it is the answer every hand-rolled check should be using.
def test_a_scoped_token_cannot_pass_a_hand_rolled_permission_check(
client, db, admin_user, auth_headers):
"""The secure-send route checks tools.secrets itself rather than through
require_permission. A scoped token whose owner holds that permission must
still be refused, because the caller is the token."""
secret = _create_token(
client, auth_headers,
scopes=['applications.create']).get_json()['data']['secret']
refused = client.post('/api/tools/secrets', json={'content': 'hello'},
headers=_pat_headers(secret))
assert refused.status_code == 403, refused.get_json()
def test_an_unscoped_token_still_passes_that_check(client, db, admin_user,
auth_headers):
"""The narrowing applies ONLY to scoped tokens - an unscoped one is the
person, and must keep working."""
secret = _create_token(
client, auth_headers, scopes=None).get_json()['data']['secret']
allowed = client.post('/api/tools/secrets', json={'content': 'hello'},
headers=_pat_headers(secret))
# Not 403 is the whole assertion: the payload is deliberately incomplete
# (secure send wants ciphertext and iv), so a 400 proves the request got
# PAST the permission check, which is what this is about.
assert allowed.status_code != 403, allowed.get_json()

View File

@@ -40,3 +40,53 @@ def test_version_delivery_metadata_defaults_to_null(client, db, auth_headers):
data = resp.get_json()['data']
assert data['sha256'] is None
assert data['silentargs'] is None
# --- a digest is a claim something checks against ---------------------------
#
# sha256 and releasedate were stored raw. An over-long digest was silently
# truncated to the column width and came back as 64 plausible hex characters -
# indistinguishable from a real SHA-256, and wrong. A malformed releasedate
# reached MySQL, which wrote 0000-00-00 and then broke every read of that
# application, with no API way to remove the row.
def _app(client, headers, name='Digest Test App'):
created = client.post('/api/applications', headers=headers,
json={'appname': name})
assert created.status_code in (200, 201), created.get_json()
data = created.get_json()['data']
return data.get('appid') or data.get('applicationid')
def test_a_digest_that_is_not_a_sha256_is_refused(client, db, auth_headers):
appid = _app(client, auth_headers, 'Digest refuse app')
for bad in ('c' * 100, 'nothex' * 10, 'abc', 'C' * 63):
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'v-%s' % bad[:4],
'sha256': bad})
assert response.status_code == 400, (bad, response.get_json())
def test_a_real_digest_is_accepted_and_lowercased(client, db, auth_headers):
appid = _app(client, auth_headers, 'Digest accept app')
digest = 'A' * 64
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'v1', 'sha256': digest})
assert response.status_code in (200, 201), response.get_json()
assert response.get_json()['data']['sha256'] == 'a' * 64
def test_a_malformed_release_date_is_refused_and_nothing_is_committed(
client, db, auth_headers):
from shopdb.core.models import AppVersion
appid = _app(client, auth_headers, 'Date refuse app')
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'poison-1',
'releasedate': 'tomorrow'})
assert response.status_code == 400, response.get_json()
assert AppVersion.query.filter_by(version='poison-1').first() is None, \
'a refused version must not be committed'

View File

@@ -0,0 +1,99 @@
"""One setting, one key.
Audit retention had two: shopdb/core/services/auditretention.py reads
`audit_retentiondays`, and the Audit & Logging settings page wrote
`audit_retention_days`. The page saved, reloaded, showed the value back, and
nothing ever consulted it - retention stayed off while the screen said it was
on. A settings page that saves a key nothing reads is worse than a missing
feature, because it tells an administrator the site is configured when it is
not.
"""
import json
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
def _seeded_keys():
"""Every setting key in the DEFAULTS list, read statically."""
source = (REPO / 'shopdb' / 'core' / 'api' / 'settings.py').read_text()
return re.findall(r"^\s*'key': '([a-z0-9_]+)'", source, re.M)
def test_only_one_audit_retention_key_is_seeded():
keys = _seeded_keys()
retention = sorted({key for key in keys if 'retention' in key})
assert retention == ['audit_retentiondays'], retention
def test_the_service_and_the_settings_pages_use_the_same_key():
"""The three places that mention retention must agree: the service that
reads it, and both settings pages that write it."""
service = (REPO / 'shopdb' / 'core' / 'services' / 'auditretention.py').read_text()
assert "audit_retentiondays" in service
assert "audit_retention_days" not in service
for page in ('AuditSettings.vue', 'SiteSettings.vue'):
text = (REPO / 'frontend' / 'src' / 'views' / 'settings' / page).read_text()
if 'retention' not in text.lower():
continue
assert 'audit_retention_days' not in text, page
assert 'audit_retentiondays' in text, page
def test_the_retention_setting_actually_drives_the_report(client, db,
auth_headers):
"""End to end: set it through the API the page uses, and read it back from
the endpoint that decides what a purge would remove."""
saved = client.put('/api/settings/audit_retentiondays',
json={'value': 30}, headers=auth_headers)
assert saved.status_code == 200, saved.get_json()
report = client.get('/api/auditlogs/retention', headers=auth_headers)
assert report.status_code == 200, report.get_json()
assert report.get_json()['data']['retentiondays'] == 30, \
'the setting the page writes must be the one the service reads'
# --- a declared valuetype is a promise to every reader -----------------------
#
# PUT /api/settings/<key> stored whatever arrived. "A6-not-a-number" went into
# an integer key verbatim, and every consumer then read 0, because
# Setting.get_typed_value swallows the ValueError. The setting looked set, the
# behaviour was the default, and nothing said so.
def test_an_integer_setting_refuses_something_that_is_not_a_number(
client, db, auth_headers):
refused = client.put('/api/settings/audit_retentiondays',
json={'value': 'not-a-number'}, headers=auth_headers)
assert refused.status_code == 400, refused.get_json()
assert 'whole-number' in refused.get_json()['data']['error']['message']
# And the old value survives, rather than being replaced by a 0 nobody
# asked for.
reading = client.get('/api/settings/audit_retentiondays',
headers=auth_headers)
assert reading.status_code == 200
assert str(reading.get_json()['data']['value']) != 'not-a-number'
def test_an_integer_setting_still_accepts_a_number(client, db, auth_headers):
accepted = client.put('/api/settings/audit_retentiondays',
json={'value': 45}, headers=auth_headers)
assert accepted.status_code == 200, accepted.get_json()
def test_a_boolean_setting_refuses_a_word_that_is_not_true_or_false(
client, db, auth_headers):
refused = client.put('/api/settings/securesend_allow_guests',
json={'value': 'maybe'}, headers=auth_headers)
assert refused.status_code == 400, refused.get_json()
def test_a_boolean_setting_accepts_the_usual_spellings(client, db,
auth_headers):
for value in (True, False, 'true', 'false', '1', '0'):
response = client.put('/api/settings/securesend_allow_guests',
json={'value': value}, headers=auth_headers)
assert response.status_code == 200, (value, response.get_json())

View File

@@ -0,0 +1,120 @@
"""No list route may answer a nonsense filter with a 500.
THE GATE, not just the six fixes
The feature test run found `int(request.args.get(...))` with nothing catching
the ValueError in six routes across five plugins - machines, network, printers,
knowledgebase, computers and the asset core. Fixing six sites does not stop a
seventh: the pattern is how a filter gets written, and it is invisible in review
because the happy path works.
So this sweeps every registered GET route that takes no path parameters and
sends each of them a set of arguments that are not what any filter wants. The
contract is narrow on purpose:
- a 5xx is ALWAYS a failure. The caller sent a bad argument; that is a 400.
- a 400 is the intended answer.
- a 200 is acceptable: a route is entitled to ignore an argument it does not
know about, and most of these routes will not have the argument at all.
- a 401/403/404 is acceptable: this test is about argument handling, not
about authorization.
Run as an admin, because a route that refuses everyone tests nothing.
"""
import pytest
# Names taken from the filters the failing routes actually declared, plus the
# generic shapes a client sends. Values chosen to break a bare int(), a bare
# float(), and a naive split.
BAD_ARGS = [
('typeid', 'abc'),
('type_id', 'abc'),
('assettypeid', '1; DROP TABLE assets'),
('vendorid', 'null'),
('locationid', '1.5'),
('businessunitid', ''),
('appid', 'abc'),
('statusid', 'NaN'),
('modelid', '-'),
('limit', 'lots'),
('page', 'abc'),
('perpage', 'abc'),
]
# Routes that are not list endpoints and whose GET does real work with side
# effects or external calls. Excluded by PREFIX, and each one says why.
SKIP_PREFIXES = (
'/api/collector/', # needs the collector key; fail-closed by design
'/api/endpoints/', # agent wire protocol, same key
'/api/backups/run', # kicks off work rather than reading
)
# Skipped by EXACT path, not prefix, so the rest of the plugin stays covered.
SKIP_EXACT = {
# These four reach the EXTERNAL cmmc_usb database. Their 500 here is "no
# such database in the test environment", a different fault from the one
# this gate is about, and the list is the complete set - every usb GET
# route whose handler calls cmmc_usb_connection. The self-hosted USB
# surface is covered by test_usb_kiosk.py.
'/api/usb',
'/api/usb/checkouts',
'/api/usb/checkouts/active',
'/api/usb/reports/audit',
}
def _listish_routes(app):
routes = []
for rule in app.url_map.iter_rules():
path = str(rule.rule)
if not path.startswith('/api/') or '<' in path:
continue
if 'GET' not in (rule.methods or ()):
continue
if path in SKIP_EXACT:
continue
if any(path.startswith(prefix) for prefix in SKIP_PREFIXES):
continue
routes.append(path)
return sorted(set(routes))
def test_the_sweep_actually_covers_something(app):
"""A gate that silently matches nothing passes forever. This one has been
known to cover 90+ routes; if it drops to a handful, the filter above is
wrong rather than the app being small."""
assert len(_listish_routes(app)) > 40
def test_no_route_answers_a_nonsense_filter_with_a_server_error(
client, auth_headers, app):
failures = []
for path in _listish_routes(app):
for name, value in BAD_ARGS:
response = client.get('%s?%s=%s' % (path, name, value),
headers=auth_headers)
if response.status_code >= 500:
failures.append('%s?%s=%s -> %d' % (path, name, value,
response.status_code))
assert not failures, (
'a bad query argument is a 400, never a 500:\n '
+ '\n '.join(failures[:40]))
@pytest.mark.parametrize('value', ['abc', '', '1.5', 'null'])
def test_a_bad_type_filter_is_a_400_that_names_the_argument(
client, auth_headers, value):
"""The routes the run actually caught. An empty value is the exception:
absent and empty both mean 'no filter', which is what a cleared dropdown
sends."""
for path in ('/api/machines', '/api/computers', '/api/network',
'/api/printers', '/api/assets'):
response = client.get('%s?typeid=%s' % (path, value),
headers=auth_headers)
if value == '':
assert response.status_code == 200, (path, response.get_json())
continue
assert response.status_code == 400, (path, response.status_code)
body = response.get_json()
assert body['data']['error']['code'] == 'VALIDATION_ERROR'
assert 'typeid' in body['data']['error']['message']

View File

@@ -0,0 +1,80 @@
"""Two ways to change a credential must not disagree.
Both of these were found by the 2026-09-03 run, and both are the same shape: a
second route that reached the same state with fewer rules than the route
designed for it.
* PUT /api/users/<self> accepted a `password` field with no current-password
proof and no length rule, while /api/auth/change-password requires both.
The weaker route is the real rule, so the stronger one was decoration.
* PUT /api/apitokens/<id> {"isactive": true} un-revoked a token, and the
ORIGINAL secret worked again. "Revoked" meant "paused until somebody flips
it back" - for a credential whose secret is, by the act of revoking it,
assumed to be out.
"""
def _token(client, headers, name='cred-test'):
created = client.post('/api/apitokens', headers=headers,
json={'name': name})
assert created.status_code in (200, 201), created.get_json()
return created.get_json()['data']
def test_a_user_cannot_change_their_own_password_through_the_users_route(
client, db, member_headers, member_user):
"""The route that asks for no current password must refuse the change, not
perform it."""
response = client.put('/api/users/%d' % member_user.userid,
headers=member_headers, json={'password': 'brandnew1'})
assert response.status_code == 403, response.get_json()
assert 'change-password' in response.get_json()['data']['error']['message']
def test_an_admin_may_still_issue_a_temporary_password(client, db,
auth_headers,
member_user):
"""Issuing one is how a locked-out user gets back in - and it is temporary
by definition, so the holder must replace it at next login."""
response = client.put('/api/users/%d' % member_user.userid,
headers=auth_headers, json={'password': 'issued12345'})
assert response.status_code == 200, response.get_json()
db.session.refresh(member_user)
assert member_user.mustchangepassword is True
def test_an_admin_issued_password_still_has_to_be_long_enough(client, db,
auth_headers,
member_user):
response = client.put('/api/users/%d' % member_user.userid,
headers=auth_headers, json={'password': 'short'})
assert response.status_code == 400, response.get_json()
def test_a_revoked_token_cannot_be_brought_back(client, db, auth_headers):
token = _token(client, auth_headers, name='cred-revoke')
tokenid = token['tokenid']
secret = token['secret']
assert client.delete('/api/apitokens/%d' % tokenid,
headers=auth_headers).status_code == 200
refused = client.put('/api/apitokens/%d' % tokenid, headers=auth_headers,
json={'isactive': True})
assert refused.status_code == 400, refused.get_json()
assert 'new token' in refused.get_json()['data']['error']['message']
# And the original secret stays dead, which is the point.
denied = client.get('/api/assets',
headers={'Authorization': 'Bearer %s' % secret})
assert denied.status_code == 401
def test_a_live_token_can_still_be_deactivated_through_the_same_route(
client, db, auth_headers):
"""One-way, not frozen: turning one OFF here still works."""
token = _token(client, auth_headers, name='cred-deactivate')
response = client.put('/api/apitokens/%d' % token['tokenid'],
headers=auth_headers, json={'isactive': False})
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['isactive'] is False

View File

@@ -24,3 +24,84 @@ def test_search_degrades_when_directory_unreachable(client, db, auth_headers):
resp = client.get('/api/employees/search?q=smith', headers=auth_headers)
assert resp.status_code == 500
assert resp.get_json()['status'] == 'error'
# --- a full name has to find the person -------------------------------------
#
# Reported from the floor: somebody types the employee's whole name into the
# notification/recognition picker and presses Enter instead of clicking the
# suggestion, and the entry does not pull from the employee database.
#
# Two causes, one symptom. The search matched the whole query against first
# name OR last name, so "Dana Reyes" matched nothing - no single column holds
# that string - and with no suggestion to click, the form turned the typed text
# into a free-text label with no SSO: no photo, no directory link, and a
# recognition the person it is about cannot be found by.
def _seed(db, first, last, sso):
"""One directory employee, in self-hosted mode.
The mode matters: without it the search goes to the external HR database,
which does not exist here and answers 500.
"""
from shopdb.core.models import Setting
from plugins.employees.models import DirectoryEmployee
row = Setting.query.filter_by(key='employee_directory_mode').first()
if row is None:
db.session.add(Setting(key='employee_directory_mode',
value='selfhosted', valuetype='string',
category='site'))
else:
row.value = 'selfhosted'
db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last))
db.session.commit()
def test_a_full_name_finds_the_employee(client, db, auth_headers):
_seed(db, 'Dana', 'Reyes', 990001)
response = client.get('/api/employees/search?q=Dana%20Reyes',
headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = response.get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990001']
def test_the_words_may_be_in_either_order(client, db, auth_headers):
"""A directory that lists "Reyes, Dana" trains people to type it that way."""
_seed(db, 'Dana', 'Reyes', 990002)
rows = client.get('/api/employees/search?q=Reyes%20Dana',
headers=auth_headers).get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990002']
def test_every_word_must_match_so_a_full_name_is_not_a_broad_search(
client, db, auth_headers):
""""Dana Reyes" must not also return Sam Reyes - that is what makes the
result unambiguous enough to select automatically."""
_seed(db, 'Dana', 'Reyes', 990003)
_seed(db, 'Sam', 'Reyes', 990004)
rows = client.get('/api/employees/search?q=Dana%20Reyes',
headers=auth_headers).get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990003']
def test_a_single_word_still_searches_broadly(client, db, auth_headers):
"""One word is a search, not a name: it must still find every match."""
_seed(db, 'Dana', 'Reyes', 990005)
_seed(db, 'Sam', 'Reyes', 990006)
rows = client.get('/api/employees/search?q=Reyes',
headers=auth_headers).get_json()['data']
assert len(rows) == 2
def test_an_sso_still_resolves(client, db, auth_headers):
_seed(db, 'Dana', 'Reyes', 990007)
rows = client.get('/api/employees/search?q=990007',
headers=auth_headers).get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990007']

View File

@@ -0,0 +1,66 @@
"""Every /api/ error answers in the documented envelope.
the API-REFERENCE wiki page says an error is
{"status":"error","data":{"error":{"code","message"}},"meta":{...}}. Three
statuses came straight from Werkzeug as HTML instead:
405 a wrong method - and on a route the caller cannot otherwise see, the
405 also CONFIRMED the path exists, so a gated kiosk route meant to
read as absent announced itself by refusing the wrong verb
415 a body sent without Content-Type: application/json
400 a body that is not parseable JSON
A client written against the contract gets a page of markup with no error code,
which is indistinguishable from the server having fallen over.
Outside /api/ the framework's own behaviour is left alone: that is the SPA, and
it has its own catch-all.
"""
def _is_envelope(response):
body = response.get_json(silent=True)
if not body:
return False
return (body.get('status') == 'error'
and 'error' in body.get('data', {})
and 'code' in body['data']['error']
and 'message' in body['data']['error']
and 'meta' in body)
def test_a_wrong_method_answers_in_the_envelope(client):
response = client.delete('/api/assets/types')
assert response.status_code == 405
assert response.content_type.startswith('application/json'), \
response.content_type
assert _is_envelope(response), response.get_data(as_text=True)[:200]
def test_a_body_with_the_wrong_content_type_answers_in_the_envelope(client):
response = client.post('/api/auth/login', data='not json',
content_type='text/plain')
assert response.status_code == 415
assert _is_envelope(response), response.get_data(as_text=True)[:200]
def test_a_malformed_json_body_answers_in_the_envelope(client):
response = client.post('/api/auth/login', data='{"username": ',
content_type='application/json')
assert response.status_code == 400
assert _is_envelope(response), response.get_data(as_text=True)[:200]
def test_a_missing_api_route_still_answers_in_the_envelope(client):
response = client.get('/api/nothing-here-at-all')
assert response.status_code == 404
assert _is_envelope(response)
def test_the_spa_catch_all_is_untouched(client):
"""A browser asking for an app route must still get the app, not JSON.
Enveloping everything would have broken deep links."""
response = client.get('/pcs/1')
assert response.status_code in (200, 404)
if response.status_code == 200:
assert not response.content_type.startswith('application/json')

View File

@@ -290,3 +290,35 @@ def test_capabilities_does_not_report_what_the_guest_role_holds(client, db,
grant(db, guestrole, 'assets.view')
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert set(body) == {'cansearch'}
# --- a grant nobody reads is worse than no grant ----------------------------
#
# tools.secrets was declared guest-grantable, so the role grid offered it and
# stored it, and the secure-send route never consulted the guest ROLE - it
# decides on securesend_allow_guests, which fails closed and carries the rate
# limit. Ticking the box changed nothing in either direction, so the screen and
# the behaviour disagreed. One decision, one mechanism.
def test_tools_secrets_is_not_offered_as_a_guest_grant(app):
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
assert 'tools.secrets' not in grantable
assert 'tools.view' in grantable, \
'the nav entry gate is a real guest grant and must stay'
def test_the_guest_role_still_saves_when_it_holds_only_grantable_permissions(
client, db, auth_headers, guestrole):
"""The deadlock guard: removing something from the grantable set must not
stop the guest role being saved (see 723a4ff)."""
from shopdb.core.models import Role
guest = Role.query.filter_by(rolename=Role.GUEST).first()
assert guest is not None
saved = client.put('/api/users/roles/%d' % guest.roleid, headers=auth_headers,
json={'permissions': ['tools.view']})
assert saved.status_code == 200, saved.get_json()

View File

@@ -75,3 +75,73 @@ def test_per_type_identifier_seed_count(client, db, auth_headers):
}
assert expected <= keys
assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES)
# --- owned location versus inherited location -------------------------------
#
# Asset.to_dict merges an inherited location into locationid/mapx/mapy/levelid
# when the asset has none of its own. That is right for a reader - a detail
# page, a list and the map all want the resolved location - and wrong for a
# FORM, which reads the same payload and cannot tell an owned value from a
# derived one. The PC form pre-selected an inherited location and saved it back
# as the record's own, so an edit that never touched location silently stopped
# the PC following its parent.
def test_an_asset_payload_says_what_it_owns_as_well_as_what_it_resolves_to(
db, app):
from shopdb.core.models import Asset, AssetType, Location, RelationshipType
from shopdb.core.models.relationship import AssetRelationship
with app.app_context():
assettype = AssetType(assettype='machine')
location = Location(locationname='Bay 7')
db.session.add_all([assettype, location])
db.session.flush()
parent = Asset(assetnumber='OWN-PARENT', assettypeid=assettype.assettypeid,
locationid=location.locationid, mapx=10, mapy=20)
child = Asset(assetnumber='OWN-CHILD', assettypeid=assettype.assettypeid)
db.session.add_all([parent, child])
db.session.flush()
controls = RelationshipType.query.filter_by(
relationshiptype='controls').first()
if controls is None:
controls = RelationshipType(relationshiptype='controls',
isdirectional=True)
db.session.add(controls)
db.session.flush()
db.session.add(AssetRelationship(
sourceassetid=child.assetid, targetassetid=parent.assetid,
relationshiptypeid=controls.relationshiptypeid))
db.session.commit()
payload = child.to_dict()
# What it OWNS: nothing. That is the value a form must bind to.
assert payload['ownlocationid'] is None
assert payload['ownmapx'] is None
assert payload['ownmapy'] is None
assert payload['ownlevelid'] is None
def test_the_owned_values_are_present_even_with_nothing_inherited(db, app):
"""A client binds to them unconditionally, so they cannot appear only when
inheritance happens to be in play."""
from shopdb.core.models import Asset, AssetType, Location
with app.app_context():
assettype = AssetType(assettype='machine')
location = Location(locationname='Bay 9')
db.session.add_all([assettype, location])
db.session.flush()
asset = Asset(assetnumber='OWN-PLAIN', assettypeid=assettype.assettypeid,
locationid=location.locationid, mapx=5, mapy=6)
db.session.add(asset)
db.session.commit()
payload = asset.to_dict()
assert payload['ownlocationid'] == payload['locationid']
assert payload['ownmapx'] == 5
assert payload['ownmapy'] == 6

View File

@@ -0,0 +1,56 @@
"""The floor-map level reads answer a caller with no account.
ADR-017 rule 6 says so, docs/api-inventory.json declares auth "none" for all
three, PrinterInstallerMap.vue carries a comment explaining why, and the module
docstring in shopdb/core/api/maplevels.py says the reads are public. The code
disagreed with all four: afce2437 swept 165 genuinely-open GET routes behind
require_permission and caught these in the net, so the anonymous installer map
drew no floor plan.
Nothing leaked - it failed closed - but a feature was off for a fortnight while
every document said it was on. test_public_read_surface.py checks that a public
route is REGISTERED as public; this checks that it actually answers, which is
the part that was broken.
"""
def _level(db):
from shopdb.core.models.maplevel import Building, MapLevel
building = Building(buildingname='Public Works', sortorder=1)
db.session.add(building)
db.session.flush()
level = MapLevel(buildingid=building.buildingid, levelname='Ground',
sortorder=1, blueprintlight='floorplan.svg')
db.session.add(level)
db.session.commit()
return level.levelid
def test_the_level_list_answers_an_anonymous_caller(client, db):
levelid = _level(db)
response = client.get('/api/maplevels')
assert response.status_code == 200, response.get_json()
payload = response.get_json()['data']
names = [level['levelname']
for building in payload['buildings']
for level in building['levels']]
assert 'Ground' in names
assert levelid
def test_one_level_answers_an_anonymous_caller(client, db):
levelid = _level(db)
response = client.get('/api/maplevels/%d' % levelid)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['levelname'] == 'Ground'
def test_writes_are_still_gated(client, db):
"""The reads going back to public must not take the writes with them."""
levelid = _level(db)
assert client.post('/api/maplevels/buildings',
json={'buildingname': 'Sneaky'}).status_code in (401, 403)
assert client.put('/api/maplevels/%d' % levelid,
json={'levelname': 'Renamed'}).status_code in (401, 403)
assert client.delete('/api/maplevels/%d' % levelid).status_code in (401, 403)

View File

@@ -0,0 +1,79 @@
"""No client may ask for a page bigger than the server will give.
MAX_PAGE_SIZE caps perpage at 100. The cap is deliberate, and it is applied
SILENTLY: a request for perpage=500 returns the first 100 rows with a 200, so
nothing anywhere reports a problem. A picker built that way shows the first 100
options and drops the rest, and the person using it has no way to tell - the
missing vendor simply is not in the list.
That shape has been found and fixed more than once, which is what makes it worth
a gate rather than a comment. Paging through is what fetchAllPages/listAll are
for.
The check reads the frontend sources rather than the running app, because the
defect is in what the caller ASKS for: the response is a valid 200 either way,
so no runtime assertion can see it.
"""
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
# Where a client is written. The staged copy under frontend/src/.plugins-staged
# is generated from plugins/*/frontend at build time, so checking the source
# avoids reporting the same line twice.
SOURCE_DIRS = [
REPO / 'frontend' / 'src',
*sorted((REPO / 'plugins').glob('*/frontend')),
]
SKIP_PARTS = {'.plugins-staged', 'node_modules', 'dist'}
# perpage: <number>, in an object literal or a query string.
PERPAGE = re.compile(r'perpage[\'"]?\s*[:=]\s*[\'"]?(\d+)', re.IGNORECASE)
MAX_PAGE_SIZE = 100
def _sources():
for root in SOURCE_DIRS:
if not root.exists():
continue
for path in root.rglob('*'):
if path.suffix not in ('.vue', '.js'):
continue
if set(path.parts) & SKIP_PARTS:
continue
if path.name.endswith('.spec.js'):
continue
yield path
def test_the_sweep_actually_reads_something():
"""A gate that matches nothing passes forever."""
files = list(_sources())
assert len(files) > 100, len(files)
assert any(PERPAGE.search(path.read_text()) for path in files), \
'no perpage usage found at all - the pattern is probably wrong'
def test_no_client_asks_for_more_rows_than_the_server_returns():
offenders = []
for path in _sources():
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
# A comment explaining the cap is not a caller asking for 1000
# rows. Crude but sufficient: this is a line-oriented check over
# source that follows one style.
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('*'):
continue
for match in PERPAGE.finditer(line):
if int(match.group(1)) > MAX_PAGE_SIZE:
offenders.append('%s:%d %s'
% (path.relative_to(REPO), lineno,
line.strip()[:90]))
assert not offenders, (
'perpage is capped at %d server-side and the excess is dropped '
'silently, so these show a truncated list with no error. Use listAll() '
'/ fetchAllPages() instead:\n %s'
% (MAX_PAGE_SIZE, '\n '.join(offenders)))

View File

@@ -44,6 +44,12 @@ PUBLIC_ENDPOINTS = {
'settings.serve_branding_logo',
'settings.serve_map_blueprint',
'employees.serve_employee_photo',
'applications.serve_application_image',
# Same reasoning: a machine detail page renders the catalog model's photo
# with <img :src="withBase(imageurl)">, which sends no Authorization
# header. A gate here does not protect the file, it only guarantees the
# photo never appears.
'models.serve_model_image',
# API documentation: shapes, not data. The docs page loads its own script.
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
@@ -59,6 +65,13 @@ PUBLIC_ENDPOINTS = {
'notifications.get_employee_recognitions',
'slides.feed', 'slides.serve_image',
# The floor-map levels and their blueprints. ADR-017 rule 6: the printer
# installer map runs optional-auth before anyone logs in and needs a
# blueprint to draw on. Names and image paths, not asset data - and the
# asset positions that go on top already render on public kiosk pages.
'maplevels.list_levels', 'maplevels.get_level',
'maplevels.serve_blueprint',
# Parts kiosk: one item by its code, and that item's picture.
# kiosk_item takes no JWT but is NOT open: same kiosk flag + device token
# as the write. Gating only the write would have left the enumeration

View File

@@ -0,0 +1,69 @@
"""A retired asset has to be findable, and must never outrank a live one.
Retire is a soft delete: the confirm dialog promises history is kept and the
record can be restored. Global search excluded inactive assets, so the record
existed, was reachable by URL, and could not be found by anybody who did not
already know where it was - including whoever wanted to restore it.
Including them is only safe with the ordering: somebody searching a machine
number almost always wants the machine still on the floor, so a retired match
is pushed below every live one and flagged, rather than being mixed in
indistinguishably.
"""
import pytest
@pytest.fixture
def assets(db):
from shopdb.core.models import Asset, AssetType
assettype = AssetType(assettype='machine')
db.session.add(assettype)
db.session.flush()
live = Asset(assetnumber='SR-1000', name='Live grinder',
assettypeid=assettype.assettypeid)
gone = Asset(assetnumber='SR-1001', name='Retired grinder',
assettypeid=assettype.assettypeid, isactive=False)
db.session.add_all([live, gone])
db.session.commit()
return live, gone
def _search(client, headers, term):
response = client.get('/api/search?q=%s' % term, headers=headers)
assert response.status_code == 200, response.get_json()
payload = response.get_json()['data']
return payload.get('results', payload if isinstance(payload, list) else [])
def test_a_retired_asset_can_be_found(client, db, auth_headers, assets):
_live, gone = assets
titles = [r['title'] for r in _search(client, auth_headers, 'SR-1001')]
assert any('Retired grinder' in title or 'SR-1001' in title
for title in titles), titles
def test_a_retired_result_says_it_is_retired(client, db, auth_headers, assets):
results = _search(client, auth_headers, 'SR-1001')
match = next(r for r in results
if 'SR-1001' in (r['title'] + (r.get('subtitle') or '')))
assert match['retired'] is True
def test_a_live_asset_outranks_a_retired_one(client, db, auth_headers, assets):
"""Both match 'grinder'; the one still on the floor comes first."""
results = [r for r in _search(client, auth_headers, 'grinder')
if r.get('retired') is not None]
assert len(results) >= 2, results
live = next(r for r in results if not r['retired'])
retired = next(r for r in results if r['retired'])
assert live['relevance'] > retired['relevance'], (live, retired)
def test_a_live_asset_is_not_flagged(client, db, auth_headers, assets):
results = _search(client, auth_headers, 'SR-1000')
match = next(r for r in results if 'SR-1000' in (r['title'] + (r.get('subtitle') or '')))
assert match['retired'] is False

View File

@@ -0,0 +1,140 @@
"""A write the database refuses is the CALLER's error, not a server fault.
The engine now runs with STRICT_TRANS_TABLES (see
shopdb/config.py: SQLALCHEMY_ENGINE_OPTIONS). Before that, MySQL silently
adjusted a write it could not store: a 257-character username became the first
100 characters, an unparseable date became '0000-00-00' and then broke every
read of that row, an over-long asset number was cut at 50 and collided with a
different one. All of them answered 200 or 201, because nothing in the stack
knew the value had changed.
Strict mode turns those into errors, which is the point - but an uncaught
DataError is a 500, and a 500 says "the server is broken" when the truth is
"that value does not fit". This pins the mapping.
These tests raise the error directly rather than through a column, because the
suite runs on SQLite, which does not enforce VARCHAR length at all: there is no
value that would trigger it here. The behaviour against the real engine was
checked by hand on MySQL 5.6 - `POST /api/users` with a 257-character username
returns 400 "That value is too long for username." and stores nothing, where it
previously stored a truncated row.
"""
import pytest
from sqlalchemy.exc import DataError
from shopdb import _column_from_data_error
# A private app, because routes can only be mounted before the first request
# and the session-wide one has already served plenty.
@pytest.fixture(scope='module')
def raising_app():
from shopdb import create_app
application = create_app('testing')
application.add_url_rule(
'/__test__/raise', 'raise_data_error',
lambda: (_ for _ in ()).throw(
_data_error(application.config['TEST_DATA_ERROR_MESSAGE'])))
return application
def _raise(app, message):
"""Answer a request whose route raises DataError, so the app's own handler
is what produces the response."""
app.config['TEST_DATA_ERROR_MESSAGE'] = message
return app.test_client().get('/__test__/raise')
def _data_error(message):
return DataError('INSERT INTO x', {}, Exception(message))
def test_a_value_too_long_is_a_400_naming_the_column(raising_app):
response = _raise(
raising_app,
"(1406, \"Data too long for column 'username' at row 1\")")
assert response.status_code == 400
body = response.get_json()
assert body['data']['error']['code'] == 'VALIDATION_ERROR'
assert 'too long' in body['data']['error']['message']
assert 'username' in body['data']['error']['message']
def test_a_bad_date_is_a_400_naming_the_column(raising_app):
response = _raise(
raising_app,
"(1292, \"Incorrect date value: 'not-a-date' for column "
"'releasedate' at row 1\")")
assert response.status_code == 400
assert 'releasedate' in response.get_json()['data']['error']['message']
def test_an_unrecognised_data_error_is_still_a_400_not_a_500(raising_app):
"""The point is the CLASS of answer. A message shape nobody anticipated
must not fall back to 'internal server error'."""
response = _raise(raising_app, '(9999, "something new")')
assert response.status_code == 400
assert response.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
@pytest.mark.parametrize('message,expected', [
("Data too long for column 'assetnumber' at row 1", 'assetnumber'),
("Incorrect date value: 'x' for column 'enddate' at row 1", 'enddate'),
('no column mentioned at all', None),
('', None),
(None, None),
])
def test_the_column_name_is_pulled_out_when_the_engine_names_one(message,
expected):
assert _column_from_data_error(message) == expected
def test_the_strict_sql_mode_is_actually_set_on_the_mysql_connection():
"""The whole class of fix rests on this one connect_arg, and it is exactly
the kind of line a later edit drops without noticing."""
from shopdb.config import Config
connect_args = Config.SQLALCHEMY_ENGINE_OPTIONS['connect_args']
assert 'STRICT_TRANS_TABLES' in connect_args['init_command']
# --- the audit log must never be what fails the write ------------------------
def test_audit_changes_serialise_the_types_a_model_actually_holds():
"""A route builds {'old': getattr(row, field)} straight off the model, so
the changes dict carries dates, datetimes and Decimals. json.dumps raises
on all three, and the exception took the whole request down: PUT
/api/machines/<id> 500d and rolled back for any machine with a maintenance
date, including a rename that never touched it."""
import datetime
import decimal
from shopdb.core.models.auditlog import AuditLog
entry = AuditLog()
entry.changes = {
'nextmaintenancedate': {'old': datetime.date(2026, 12, 15),
'new': '2027-01-05'},
'lastseen': {'old': datetime.datetime(2026, 1, 2, 3, 4, 5),
'new': None},
'cost': {'old': decimal.Decimal('12.50'), 'new': 13},
}
stored = entry.changes
assert stored['nextmaintenancedate']['old'] == '2026-12-15'
assert stored['lastseen']['old'] == '2026-01-02T03:04:05'
assert stored['cost']['old'] == 12.5
def test_an_unserialisable_value_is_recorded_rather_than_raising():
"""An audit entry that reads awkwardly beats a failed write: the
alternative is losing the change AND the record of it."""
from shopdb.core.models.auditlog import AuditLog
class Odd:
def __repr__(self):
return '<Odd value>'
entry = AuditLog()
entry.changes = {'thing': {'old': Odd(), 'new': None}}
assert entry.changes['thing']['old'] == '<Odd value>'

View File

@@ -72,8 +72,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor, then
# adds scannedviruses so an imported external history keeps the scan result -
# the field a removable-media audit actually asks about.
EXPECTED_HEAD_REVISION['usb'] = 'usb0003scanfields'
# the field a removable-media audit actually asks about - and finally records
# who handed a drive back, which is not always who took it out.
EXPECTED_HEAD_REVISION['usb'] = 'usb0004checkinidentity'
# network links a device to a catalog model, which is where its photo comes
# from - machines, PCs and printers already had that link.
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'

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