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

@@ -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>'