Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
99
tests/test_core/test_audit_retention_key.py
Normal file
99
tests/test_core/test_audit_retention_key.py
Normal 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())
|
||||
120
tests/test_core/test_bad_query_args.py
Normal file
120
tests/test_core/test_bad_query_args.py
Normal 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']
|
||||
80
tests/test_core/test_credential_paths.py
Normal file
80
tests/test_core/test_credential_paths.py
Normal 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
|
||||
@@ -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']
|
||||
|
||||
66
tests/test_core/test_error_envelope.py
Normal file
66
tests/test_core/test_error_envelope.py
Normal 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')
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
56
tests/test_core/test_maplevels_public.py
Normal file
56
tests/test_core/test_maplevels_public.py
Normal 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)
|
||||
79
tests/test_core/test_perpage_cap.py
Normal file
79
tests/test_core/test_perpage_cap.py
Normal 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)))
|
||||
@@ -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
|
||||
|
||||
69
tests/test_core/test_search_retired.py
Normal file
69
tests/test_core/test_search_retired.py
Normal 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
|
||||
140
tests/test_core/test_strict_write_errors.py
Normal file
140
tests/test_core/test_strict_write_errors.py
Normal 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>'
|
||||
Reference in New Issue
Block a user