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>'
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
104
tests/test_plugins/test_computers_audit.py
Normal file
104
tests/test_plugins/test_computers_audit.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
80
tests/test_plugins/test_network_validation.py
Normal file
80
tests/test_plugins/test_network_validation.py
Normal 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'
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user