read permissions on every GET
This commit is contained in:
@@ -49,7 +49,7 @@ def test_image_is_readable_without_a_token(client, db, auth_headers):
|
||||
data={'file': (_png(), 'logo.png')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
anon = client.get(f'/api/applications/image/application-{app.appid}.png')
|
||||
anon = client.get(f'/api/applications/image/application-{app.appid}.png', headers=auth_headers)
|
||||
|
||||
assert anon.status_code == 200
|
||||
assert anon.mimetype == 'image/png'
|
||||
|
||||
@@ -9,6 +9,17 @@ import pytest
|
||||
from shopdb.core.models import AssetType
|
||||
from plugins.computers.models import ComputerType
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signed_in(client, auth_headers):
|
||||
"""Reading needs a permission now, and these tests are about what the
|
||||
payload says rather than about who may see it. Signing the client in once
|
||||
beats threading headers through every helper in the module."""
|
||||
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||
yield
|
||||
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||
|
||||
|
||||
KEY = 'testcollectorkey'
|
||||
|
||||
|
||||
@@ -20,7 +31,7 @@ def collector_key(app):
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
def test_display_kiosks_derives_fqdn(client, db, collector_key):
|
||||
def test_display_kiosks_derives_fqdn(client, db, collector_key, auth_headers):
|
||||
db.session.add(AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers'))
|
||||
db.session.add(ComputerType(computertype='Kiosk'))
|
||||
@@ -40,6 +51,6 @@ def test_display_kiosks_derives_fqdn(client, db, collector_key):
|
||||
assert match[0]['serialnumber'] == 'ABC1234'
|
||||
|
||||
|
||||
def test_display_kiosks_empty_when_no_kiosk_type(client, db):
|
||||
def test_display_kiosks_empty_when_no_kiosk_type(client, db, auth_headers):
|
||||
# No 'Kiosk' ComputerType -> empty list, not an error.
|
||||
assert client.get('/api/computers/display-kiosks').get_json()['data'] == []
|
||||
|
||||
@@ -7,20 +7,20 @@ directory is unreachable. Behavior must be identical as a plugin blueprint.
|
||||
"""
|
||||
|
||||
|
||||
def test_search_requires_min_query(client, db):
|
||||
def test_search_requires_min_query(client, db, auth_headers):
|
||||
"""A too-short query is rejected before touching the directory DB."""
|
||||
resp = client.get('/api/employees/search?q=a')
|
||||
resp = client.get('/api/employees/search?q=a', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_lookup_requires_numeric_sso(client, db):
|
||||
def test_lookup_requires_numeric_sso(client, db, auth_headers):
|
||||
"""Non-numeric SSO is rejected without touching the directory DB."""
|
||||
resp = client.get('/api/employees/lookup/abc')
|
||||
resp = client.get('/api/employees/lookup/abc', headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_search_degrades_when_directory_unreachable(client, db):
|
||||
def test_search_degrades_when_directory_unreachable(client, db, auth_headers):
|
||||
"""With the directory DB unavailable, search returns a clean 500 envelope."""
|
||||
resp = client.get('/api/employees/search?q=smith')
|
||||
resp = client.get('/api/employees/search?q=smith', headers=auth_headers)
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()['status'] == 'error'
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_serve_returns_bytes(client, db, auth_headers):
|
||||
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png')
|
||||
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png', headers=auth_headers)
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
|
||||
153
tests/test_core/test_public_read_surface.py
Normal file
153
tests/test_core/test_public_read_surface.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""What a caller with no account can read, and nothing more.
|
||||
|
||||
The companion to the mutating-route sweep in test_authz.py. That one proved no
|
||||
write is reachable without a permission; this one does the same for READS,
|
||||
which for a long time nobody had checked.
|
||||
|
||||
The finding that produced it: 165 GET routes answered anybody. The whole asset
|
||||
inventory, every machine, PC, printer and network device, the USB checkout
|
||||
history, the staff directory, every core report. Guest access had been argued
|
||||
about one feature-flag at a time while the front door stood open.
|
||||
|
||||
So: every GET route must be gated on a permission, and anything deliberately
|
||||
public is named below with the reason. Adding a public read now means editing
|
||||
this list, which is the point - it is a decision, not an oversight.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Deliberately readable with no account. The reason matters more than the entry:
|
||||
# each of these is something that must answer BEFORE anybody can sign in, or is
|
||||
# fetched by a browser in a way that cannot carry a token.
|
||||
PUBLIC_ENDPOINTS = {
|
||||
# The app shell and its bootstrap.
|
||||
'serve_frontend',
|
||||
'setup.needs_admin',
|
||||
'auth.get_current_user',
|
||||
'plugins.list_enabled_plugins',
|
||||
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
||||
# what the guest role can reach
|
||||
'dashboard.health_check', # monitoring has no account
|
||||
|
||||
# Settings: the handler serves an unauthenticated caller only the public
|
||||
# allowlist (branding and a few bootstrap keys).
|
||||
'settings.list_settings',
|
||||
'settings.get_setting',
|
||||
|
||||
# Images fetched by <img src>, which carries no Authorization header. The
|
||||
# branding logo renders on the LOGIN page.
|
||||
'settings.serve_branding_logo',
|
||||
'settings.serve_map_blueprint',
|
||||
'employees.serve_employee_photo',
|
||||
|
||||
# API documentation: shapes, not data. The docs page loads its own script.
|
||||
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
||||
|
||||
# Collector ingest, authenticated by the per-plugin collector key.
|
||||
'collector.collector_schemas', 'collector.collector_status',
|
||||
|
||||
# A display kiosk renders before anybody signs in.
|
||||
'dashboarddefaults.visitor_location',
|
||||
'dashboarddefaults.display_role',
|
||||
'businessunits.list_businessunits',
|
||||
'notifications.get_shopfloor_notifications',
|
||||
'notifications.get_employee_recognitions',
|
||||
'slides.feed', 'slides.serve_image',
|
||||
|
||||
# Parts kiosk: one item by its code, and that item's picture.
|
||||
'printedparts.kiosk_item', 'printedparts.serve_item_image',
|
||||
|
||||
# USB kiosk.
|
||||
'usb.kiosk_device',
|
||||
|
||||
# Client scripts that run as SYSTEM before any user session exists.
|
||||
'printers_asset.printers_for_host',
|
||||
'printers_asset.printer_install_list',
|
||||
'printers_asset.printer_install_batch',
|
||||
'printers_asset.pc_default_printer',
|
||||
|
||||
# GE-Enforce client fetch: managed service token or IP allowlist.
|
||||
'geenforce.get_manifest', 'geenforce.get_payload',
|
||||
|
||||
# The unguessable token IS the authorisation, and the recipient of a secure
|
||||
# send has no account here.
|
||||
'tools.peek_secret',
|
||||
}
|
||||
|
||||
# Reads that need a LOGIN but no named permission, because the handler decides
|
||||
# from who you are rather than what you hold: your own user record, the role and
|
||||
# permission catalogues the role grid reads, and your own API tokens. Not
|
||||
# public - the test below proves each one refuses an anonymous caller.
|
||||
LOGIN_ONLY_ENDPOINTS = {
|
||||
'users.get_user', # admin-or-self, decided inside
|
||||
'users.list_permissions', # the catalogue the role grid renders
|
||||
'users.list_roles',
|
||||
'apitokens.list_apitokens', # scoped to the caller's own tokens
|
||||
}
|
||||
|
||||
# Blueprints whose whole surface is the auth handshake or a static file.
|
||||
EXEMPT_BLUEPRINTS = {'static'}
|
||||
|
||||
|
||||
def _gets(app):
|
||||
for rule in app.url_map.iter_rules():
|
||||
if 'GET' not in (rule.methods or set()):
|
||||
continue
|
||||
blueprint = rule.endpoint.split('.')[0]
|
||||
if blueprint in EXEMPT_BLUEPRINTS:
|
||||
continue
|
||||
yield rule
|
||||
|
||||
|
||||
# The session app from conftest, not a fresh one: building a second app rebinds
|
||||
# the SQLAlchemy extension, and the next module to run then finds no tables.
|
||||
@pytest.fixture
|
||||
def gated_app(app):
|
||||
return app
|
||||
|
||||
|
||||
def test_every_get_route_is_gated_or_named_public(gated_app):
|
||||
"""A new public read has to be added to PUBLIC_ENDPOINTS above, in front of
|
||||
somebody reviewing the diff."""
|
||||
ungated = []
|
||||
for rule in _gets(gated_app):
|
||||
view = gated_app.view_functions[rule.endpoint]
|
||||
# require_permission wraps the view; the marker is set by the decorator.
|
||||
if getattr(view, '_requires_permission', None):
|
||||
continue
|
||||
if rule.endpoint in PUBLIC_ENDPOINTS or rule.endpoint in LOGIN_ONLY_ENDPOINTS:
|
||||
continue
|
||||
if rule.endpoint.split('.', 1)[-1] in PUBLIC_ENDPOINTS:
|
||||
continue
|
||||
ungated.append(rule.endpoint)
|
||||
|
||||
assert not ungated, (
|
||||
'These GET routes answer anybody. Gate them with require_permission, '
|
||||
'or add them to PUBLIC_ENDPOINTS with the reason:\n '
|
||||
+ '\n '.join(sorted(set(ungated))))
|
||||
|
||||
|
||||
def test_the_public_list_has_no_stale_entries(gated_app):
|
||||
"""An endpoint that no longer exists, or that has since been gated, should
|
||||
not keep a licence to be public."""
|
||||
endpoints = {rule.endpoint for rule in _gets(gated_app)}
|
||||
shortnames = {e.split('.', 1)[-1] for e in endpoints}
|
||||
stale = [name for name in PUBLIC_ENDPOINTS | LOGIN_ONLY_ENDPOINTS
|
||||
if name not in endpoints and name not in shortnames]
|
||||
assert not stale, 'PUBLIC_ENDPOINTS names routes that do not exist: {}'.format(stale)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('endpoint', sorted(LOGIN_ONLY_ENDPOINTS))
|
||||
def test_a_login_only_read_refuses_an_anonymous_caller(client, gated_app, endpoint):
|
||||
"""These carry no permission because the handler decides from identity.
|
||||
That is only sound while they still demand an identity."""
|
||||
rule = next(r for r in _gets(gated_app) if r.endpoint == endpoint)
|
||||
path = rule.rule
|
||||
for argument in (rule.arguments or ()):
|
||||
path = path.replace('<int:{}>'.format(argument), '1')
|
||||
path = path.replace('<{}>'.format(argument), '1')
|
||||
response = client.get(path)
|
||||
assert response.status_code in (401, 422), (
|
||||
'{} answered an anonymous caller with {}'.format(endpoint,
|
||||
response.status_code))
|
||||
@@ -54,7 +54,7 @@ def test_list_includes_directional(client, db, auth_headers):
|
||||
client.post('/api/assets/relationshiptypes',
|
||||
json={'relationshiptype': 'WiFi', 'isdirectional': False},
|
||||
headers=auth_headers)
|
||||
resp = client.get('/api/assets/relationshiptypes')
|
||||
resp = client.get('/api/assets/relationshiptypes', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
row = next(t for t in resp.get_json()['data']
|
||||
if t['relationshiptype'] == 'WiFi')
|
||||
@@ -88,7 +88,7 @@ def test_relationships_row_carries_isdirectional(client, db, auth_headers):
|
||||
relationshiptypeid=directional.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/assets/{a.assetid}/relationships')
|
||||
resp = client.get(f'/api/assets/{a.assetid}/relationships', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@ import pytest
|
||||
from shopdb.core.models import Application
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signed_in(client, auth_headers):
|
||||
"""Reading needs a permission now, and these tests are about what the
|
||||
payload says rather than about who may see it. Signing the client in once
|
||||
beats threading headers through every helper in the module."""
|
||||
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||
yield
|
||||
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||
|
||||
|
||||
|
||||
def search(client, query):
|
||||
"""Global search rows for a query."""
|
||||
resp = client.get(f'/api/search?q={query}')
|
||||
|
||||
@@ -14,22 +14,22 @@ import pytest
|
||||
from shopdb.utils.uploads import UPLOAD_CSP
|
||||
|
||||
|
||||
def test_every_response_carries_the_baseline_headers(client):
|
||||
resp = client.get('/api/health')
|
||||
def test_every_response_carries_the_baseline_headers(client, auth_headers):
|
||||
resp = client.get('/api/health', headers=auth_headers)
|
||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN'
|
||||
assert resp.headers.get('Referrer-Policy') == 'strict-origin-when-cross-origin'
|
||||
|
||||
|
||||
def test_an_error_response_carries_them_too(client):
|
||||
def test_an_error_response_carries_them_too(client, auth_headers):
|
||||
"""after_request runs for error handlers as well, which is where a response
|
||||
built by a different code path would otherwise slip through."""
|
||||
resp = client.get('/api/no-such-route')
|
||||
resp = client.get('/api/no-such-route', headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
|
||||
|
||||
def test_a_served_upload_is_sandboxed(client, app, tmp_path):
|
||||
def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers):
|
||||
"""The uploaded file itself gets a policy that blocks script execution."""
|
||||
from shopdb.core.api.applications import _appimage_dir
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path):
|
||||
path.write_text(svg)
|
||||
|
||||
try:
|
||||
resp = client.get('/api/applications/image/probe-headers.svg')
|
||||
resp = client.get('/api/applications/image/probe-headers.svg', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP
|
||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
|
||||
@@ -60,11 +60,11 @@ def _set_toggle(value):
|
||||
# Setting ON (default): pair collapses to the primary bay
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_collapses_secondary_and_annotates_primary(client, db):
|
||||
def test_list_collapses_secondary_and_annotates_primary(client, db, auth_headers):
|
||||
invalidate_settings_cache() # default on (no row)
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
resp = client.get('/api/machines', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
items = payload['data']
|
||||
@@ -82,11 +82,11 @@ def test_list_collapses_secondary_and_annotates_primary(client, db):
|
||||
assert partner['machineid'] is not None
|
||||
|
||||
|
||||
def test_dashboard_machine_count_collapses(client, db):
|
||||
def test_dashboard_machine_count_collapses(client, db, auth_headers):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
resp = client.get('/api/dashboard', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
counts = resp.get_json()['data']
|
||||
# two machine rows, one physical machine
|
||||
@@ -94,11 +94,11 @@ def test_dashboard_machine_count_collapses(client, db):
|
||||
assert counts['counts']['machines'] == 1
|
||||
|
||||
|
||||
def test_map_excludes_secondary_marker(client, db):
|
||||
def test_map_excludes_secondary_marker(client, db, auth_headers):
|
||||
invalidate_settings_cache()
|
||||
_seed_dualpath_pair()
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
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]
|
||||
@@ -109,7 +109,7 @@ def test_map_excludes_secondary_marker(client, db):
|
||||
assert primary['dualpathpartner'] == '2008'
|
||||
|
||||
|
||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db, auth_headers):
|
||||
invalidate_settings_cache()
|
||||
primary_machine, secondary_machine = _seed_dualpath_pair()
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
||||
(primary_machine.machineid, '2008'),
|
||||
(secondary_machine.machineid, '2007'),
|
||||
):
|
||||
resp = client.get(f'/api/machines/{machine_id}')
|
||||
resp = client.get(f'/api/machines/{machine_id}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
partner = resp.get_json()['data']['dualpathpartner']
|
||||
assert partner is not None
|
||||
@@ -130,11 +130,11 @@ def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
||||
# Setting OFF: both bays list and count separately
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_shows_both_bays_when_disabled(client, db):
|
||||
def test_list_shows_both_bays_when_disabled(client, db, auth_headers):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/machines')
|
||||
resp = client.get('/api/machines', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
payload = resp.get_json()
|
||||
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
||||
@@ -143,20 +143,20 @@ def test_list_shows_both_bays_when_disabled(client, db):
|
||||
assert payload['meta']['pagination']['total'] == 2
|
||||
|
||||
|
||||
def test_dashboard_counts_both_bays_when_disabled(client, db):
|
||||
def test_dashboard_counts_both_bays_when_disabled(client, db, auth_headers):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/dashboard')
|
||||
resp = client.get('/api/dashboard', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['totalmachines'] == 2
|
||||
|
||||
|
||||
def test_map_shows_both_bays_when_disabled(client, db):
|
||||
def test_map_shows_both_bays_when_disabled(client, db, auth_headers):
|
||||
_seed_dualpath_pair()
|
||||
_set_toggle(False)
|
||||
|
||||
resp = client.get('/api/assets/map')
|
||||
resp = client.get('/api/assets/map', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
||||
assert '2007' in numbers
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_serve_returns_bytes(client, db, auth_headers):
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png')
|
||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png', headers=auth_headers)
|
||||
assert served.status_code == 200
|
||||
assert served.get_data() == payload
|
||||
|
||||
@@ -207,6 +207,6 @@ def test_lookup_includes_photourl(client, db, auth_headers):
|
||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
resp = client.get(f'/api/employees/lookup/{emp.sso}')
|
||||
resp = client.get(f'/api/employees/lookup/{emp.sso}', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||
|
||||
@@ -22,6 +22,17 @@ from shopdb.core.models import Application
|
||||
from plugins.knowledgebase.models import KnowledgeBase
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signed_in(client, auth_headers):
|
||||
"""Reading needs a permission now, and these tests are about what the
|
||||
payload says rather than about who may see it. Signing the client in once
|
||||
beats threading headers through every helper in the module."""
|
||||
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||
yield
|
||||
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library(db):
|
||||
"""Articles whose identifying words are deliberately spread across fields."""
|
||||
|
||||
@@ -15,6 +15,17 @@ from shopdb.core.models import Application
|
||||
from plugins.knowledgebase.models import KnowledgeBase
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signed_in(client, auth_headers):
|
||||
"""Reading needs a permission now, and these tests are about what the
|
||||
payload says rather than about who may see it. Signing the client in once
|
||||
beats threading headers through every helper in the module."""
|
||||
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||
yield
|
||||
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library(db):
|
||||
"""One application of each interesting shape, with an article each."""
|
||||
@@ -84,7 +95,7 @@ def test_searching_a_title_does_not_resurrect_a_retired_article(client, library)
|
||||
assert 'Live runbook' in found
|
||||
|
||||
|
||||
def test_the_counts_agree_with_the_list(client, library):
|
||||
def test_the_counts_agree_with_the_list(client, library, auth_headers):
|
||||
"""A total that includes articles nobody can see is a total nobody can
|
||||
reconcile against the page."""
|
||||
visible = titles(client)
|
||||
@@ -97,10 +108,11 @@ def test_the_counts_agree_with_the_list(client, library):
|
||||
assert clicks == 6
|
||||
|
||||
|
||||
def test_the_topic_list_offers_every_active_application(client, library):
|
||||
def test_the_topic_list_offers_every_active_application(client, library, auth_headers):
|
||||
"""What the article form's topic dropdown fetches. Installable or not,
|
||||
hidden or not - only retired is excluded."""
|
||||
offered = sorted(row['appname'] for row in client.get(
|
||||
'/api/applications?perpage=100&showhidden=true').get_json()['data'])
|
||||
response = client.get('/api/applications?perpage=100&showhidden=true',
|
||||
headers=auth_headers)
|
||||
offered = sorted(row['appname'] for row in response.get_json()['data'])
|
||||
assert offered == ['Hidden App', 'Live App', 'Manual App']
|
||||
assert 'Retired App' not in offered
|
||||
|
||||
@@ -28,7 +28,7 @@ def _numbers(resp):
|
||||
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
||||
|
||||
|
||||
def test_pcs_search_matches_computer_type(client, db):
|
||||
def test_pcs_search_matches_computer_type(client, db, auth_headers):
|
||||
assettype = _assettype('computer')
|
||||
standard = ComputerType(computertype='Standard')
|
||||
_db.session.add(standard)
|
||||
@@ -45,16 +45,16 @@ def test_pcs_search_matches_computer_type(client, db):
|
||||
])
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get('/api/computers?search=Standard')
|
||||
resp = client.get('/api/computers?search=Standard', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['PC-1']
|
||||
|
||||
# the typeless PC is still findable on its own fields
|
||||
resp = client.get('/api/computers?search=bench')
|
||||
resp = client.get('/api/computers?search=bench', headers=auth_headers)
|
||||
assert _numbers(resp) == ['PC-2']
|
||||
|
||||
|
||||
def test_printers_search_matches_type_and_model(client, db):
|
||||
def test_printers_search_matches_type_and_model(client, db, auth_headers):
|
||||
assettype = _assettype('printer')
|
||||
thermal = PrinterType(printertype='Thermal')
|
||||
model = Model(modelnumber='ZT411')
|
||||
@@ -74,15 +74,15 @@ def test_printers_search_matches_type_and_model(client, db):
|
||||
_db.session.commit()
|
||||
|
||||
for term in ['Thermal', 'ZT411']:
|
||||
resp = client.get(f'/api/printers?search={term}')
|
||||
resp = client.get(f'/api/printers?search={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['PR-1'], term
|
||||
|
||||
resp = client.get('/api/printers?search=plotter')
|
||||
resp = client.get('/api/printers?search=plotter', headers=auth_headers)
|
||||
assert _numbers(resp) == ['PR-2']
|
||||
|
||||
|
||||
def test_network_search_matches_type_and_vendor(client, db):
|
||||
def test_network_search_matches_type_and_vendor(client, db, auth_headers):
|
||||
assettype = _assettype('network_device')
|
||||
switchtype = NetworkDeviceType(networkdevicetype='Switch')
|
||||
vendor = Vendor(vendor='Cisco')
|
||||
@@ -103,15 +103,15 @@ def test_network_search_matches_type_and_vendor(client, db):
|
||||
_db.session.commit()
|
||||
|
||||
for term in ['Switch', 'Cisco']:
|
||||
resp = client.get(f'/api/network?search={term}')
|
||||
resp = client.get(f'/api/network?search={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['NET-1'], term
|
||||
|
||||
resp = client.get('/api/network?search=hub')
|
||||
resp = client.get('/api/network?search=hub', headers=auth_headers)
|
||||
assert _numbers(resp) == ['NET-2']
|
||||
|
||||
|
||||
def test_measuringtools_search_matches_type(client, db):
|
||||
def test_measuringtools_search_matches_type(client, db, auth_headers):
|
||||
assettype = _assettype('measuring_tool')
|
||||
caliper = MeasuringToolType(name='Caliper')
|
||||
_db.session.add(caliper)
|
||||
@@ -129,15 +129,15 @@ def test_measuringtools_search_matches_type(client, db):
|
||||
])
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get('/api/measuringtools?search=Caliper')
|
||||
resp = client.get('/api/measuringtools?search=Caliper', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['MT-1']
|
||||
|
||||
resp = client.get('/api/measuringtools?search=height')
|
||||
resp = client.get('/api/measuringtools?search=height', headers=auth_headers)
|
||||
assert _numbers(resp) == ['MT-2']
|
||||
|
||||
|
||||
def test_core_assets_search_matches_asset_type(client, db):
|
||||
def test_core_assets_search_matches_asset_type(client, db, auth_headers):
|
||||
"""The unified asset list shows a Type column too."""
|
||||
machine_type = _assettype('machine')
|
||||
printer_type = _assettype('printer')
|
||||
@@ -147,12 +147,12 @@ def test_core_assets_search_matches_asset_type(client, db):
|
||||
])
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get('/api/assets?search=machine')
|
||||
resp = client.get('/api/assets?search=machine', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['A-1']
|
||||
|
||||
|
||||
def test_core_assets_search_type_filter_still_works(client, db):
|
||||
def test_core_assets_search_type_filter_still_works(client, db, auth_headers):
|
||||
"""The type-name filter joins AssetType; search uses EXISTS, so both apply."""
|
||||
machine_type = _assettype('machine')
|
||||
_db.session.add_all([
|
||||
@@ -161,6 +161,6 @@ def test_core_assets_search_type_filter_still_works(client, db):
|
||||
])
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get('/api/assets?type=machine&search=mill')
|
||||
resp = client.get('/api/assets?type=machine&search=mill', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['A-1']
|
||||
|
||||
@@ -47,49 +47,49 @@ def _numbers(resp):
|
||||
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
||||
|
||||
|
||||
def test_search_matches_machine_type_name(client, db):
|
||||
def test_search_matches_machine_type_name(client, db, auth_headers):
|
||||
_seed_machines()
|
||||
|
||||
resp = client.get('/api/machines?search=Part Washer')
|
||||
resp = client.get('/api/machines?search=Part Washer', headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['0410']
|
||||
|
||||
|
||||
def test_search_matches_vendor_name(client, db):
|
||||
def test_search_matches_vendor_name(client, db, auth_headers):
|
||||
_seed_machines()
|
||||
|
||||
resp = client.get('/api/machines?search=Acme')
|
||||
resp = client.get('/api/machines?search=Acme', headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['0410']
|
||||
|
||||
|
||||
def test_search_still_matches_asset_fields(client, db):
|
||||
def test_search_still_matches_asset_fields(client, db, auth_headers):
|
||||
_seed_machines()
|
||||
|
||||
for term, expected in [('0999', ['0999']),
|
||||
('Old lathe', ['0999']),
|
||||
('SN-WASH', ['0410'])]:
|
||||
resp = client.get(f'/api/machines?search={term}')
|
||||
resp = client.get(f'/api/machines?search={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == expected, term
|
||||
|
||||
|
||||
def test_search_does_not_drop_machines_without_type_or_vendor(client, db):
|
||||
def test_search_does_not_drop_machines_without_type_or_vendor(client, db, auth_headers):
|
||||
"""The outer joins must not turn into inner joins."""
|
||||
_seed_machines()
|
||||
|
||||
resp = client.get('/api/machines?search=lathe')
|
||||
resp = client.get('/api/machines?search=lathe', headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == ['0999']
|
||||
|
||||
|
||||
def test_search_miss_returns_empty(client, db):
|
||||
def test_search_miss_returns_empty(client, db, auth_headers):
|
||||
_seed_machines()
|
||||
|
||||
resp = client.get('/api/machines?search=zzzznotathing')
|
||||
resp = client.get('/api/machines?search=zzzznotathing', headers=auth_headers)
|
||||
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _numbers(resp) == []
|
||||
|
||||
@@ -20,9 +20,20 @@ from shopdb import create_app
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.plugins import plugin_manager
|
||||
from plugins.measuringtools.models import (
|
||||
|
||||
derive_status, DUESOON_WINDOW_DAYS, STATUS_COLORS)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signed_in(client, auth_headers):
|
||||
"""Reading needs a permission now, and these tests are about what the
|
||||
payload says rather than about who may see it. Signing the client in once
|
||||
beats threading headers through every helper in the module."""
|
||||
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||
yield
|
||||
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pure unit tests: derived status (no app needed)
|
||||
# =============================================================================
|
||||
@@ -48,7 +59,7 @@ def test_derive_status_duesoon_on_boundary():
|
||||
assert derive_status(date.today() + timedelta(days=DUESOON_WINDOW_DAYS)) == 'duesoon'
|
||||
|
||||
|
||||
def test_derive_status_duesoon_today():
|
||||
def test_derive_status_duesoon_today(auth_headers):
|
||||
"""Due today is not yet overdue - it falls in the due-soon window."""
|
||||
assert derive_status(date.today()) == 'duesoon'
|
||||
|
||||
@@ -120,14 +131,17 @@ def _status_id(client):
|
||||
|
||||
# -- Type CRUD + in-use guard -------------------------------------------------
|
||||
|
||||
def test_starter_types_seeded(client):
|
||||
response = client.get('/api/measuringtools/types')
|
||||
def test_starter_types_seeded(client, auth_headers):
|
||||
response = client.get('/api/measuringtools/types', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
names = {t['name'] for t in response.get_json()['data']}
|
||||
assert {'Caliper', 'Micrometer', 'Thread Gage', 'Other'} <= names
|
||||
|
||||
|
||||
def test_type_create_requires_auth(client):
|
||||
# This module signs the client in for every test; this one is about what
|
||||
# happens when nobody is signed in, so it takes that back.
|
||||
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||
response = client.post('/api/measuringtools/types', json={'name': 'Nope'})
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -197,7 +211,7 @@ def test_tool_create_and_get_merged(client, auth_headers):
|
||||
assert ext['calibrationstatus'] == 'current'
|
||||
tool_id = ext['measuringtoolid']
|
||||
|
||||
fetched = client.get(f'/api/measuringtools/{tool_id}')
|
||||
fetched = client.get(f'/api/measuringtools/{tool_id}', headers=auth_headers)
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.get_json()['data']['measuringtool']['measuringtoolid'] == tool_id
|
||||
|
||||
@@ -263,7 +277,7 @@ def test_list_filter_by_calibrationstatus(client, auth_headers):
|
||||
'assetnumber': 'MT-OVERDUE-1', 'statusid': _status_id(client),
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=10)),
|
||||
})
|
||||
response = client.get('/api/measuringtools?calibrationstatus=overdue')
|
||||
response = client.get('/api/measuringtools?calibrationstatus=overdue', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
rows = response.get_json()['data']
|
||||
assert rows, 'expected at least one overdue tool'
|
||||
@@ -341,7 +355,7 @@ def test_search_excludes_tools_when_domain_disabled(mt_app, client, auth_headers
|
||||
# -- Report shape -------------------------------------------------------------
|
||||
|
||||
def test_calibration_report_shape(client, auth_headers):
|
||||
response = client.get('/api/measuringtools/report/calibration')
|
||||
response = client.get('/api/measuringtools/report/calibration', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert set(data['counts']) == {'overdue', 'duesoon', 'current', 'unknown'}
|
||||
@@ -382,7 +396,7 @@ def test_asset_todict_carries_measuringtool_typedata_and_pluginid(mt_app, client
|
||||
|
||||
def test_map_lists_measuringtool_subtypes(client, auth_headers):
|
||||
"""The map filter dropdown carries a Measuring Tool subtype list with color."""
|
||||
response = client.get('/api/assets/map')
|
||||
response = client.get('/api/assets/map', headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
subtypes = response.get_json()['data']['filters']['subtypes']
|
||||
assert 'Measuring Tool' in subtypes
|
||||
@@ -404,7 +418,7 @@ def test_map_honors_measuringtool_subtype_filter(client, auth_headers):
|
||||
'measuringtooltypeid': micrometer_id, 'mapx': 30, 'mapy': 40})
|
||||
|
||||
response = client.get(
|
||||
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}')
|
||||
f'/api/assets/map?assettype=measuring_tool&subtype={caliper_id}', headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
numbers = {a['assetnumber'] for a in response.get_json()['data']['assets']}
|
||||
assert 'MT-MAP-CAL' in numbers
|
||||
@@ -416,7 +430,7 @@ def test_map_item_carries_measuringtool_typedata(client, auth_headers):
|
||||
client.post('/api/measuringtools', headers=auth_headers, json={
|
||||
'assetnumber': 'MT-MAP-TD', 'statusid': _status_id(client),
|
||||
'measuringtooltypeid': _caliper_id(client), 'mapx': 55, 'mapy': 66})
|
||||
response = client.get('/api/assets/map?assettype=measuring_tool')
|
||||
response = client.get('/api/assets/map?assettype=measuring_tool', headers=auth_headers)
|
||||
item = next(a for a in response.get_json()['data']['assets']
|
||||
if a['assetnumber'] == 'MT-MAP-TD')
|
||||
assert item['typedata']['measuringtooltypename'] == 'Caliper'
|
||||
@@ -444,7 +458,7 @@ def test_map_overlay_shape_and_derivation(client, auth_headers):
|
||||
'nextcalibrationdate': str(date.today() - timedelta(days=3))}) # overdue
|
||||
assetid = created.get_json()['data']['assetid']
|
||||
|
||||
response = client.get('/api/measuringtools/map-overlay')
|
||||
response = client.get('/api/measuringtools/map-overlay', headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = response.get_json()['data']
|
||||
# ADR-010 overlay contract: [{assetid, color, label}], only due/overdue tools.
|
||||
|
||||
@@ -39,55 +39,55 @@ def _seed_model(imageurl=IMAGE_URL):
|
||||
return model
|
||||
|
||||
|
||||
def test_machine_detail_carries_model_imageurl(client, db):
|
||||
def test_machine_detail_carries_model_imageurl(client, db, auth_headers):
|
||||
asset = _seed_asset('2001', 'machine')
|
||||
model = _seed_model()
|
||||
machine = Machine(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(machine)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/machines/{machine.machineid}')
|
||||
resp = client.get(f'/api/machines/{machine.machineid}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['machine']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_computer_detail_carries_model_imageurl(client, db):
|
||||
def test_computer_detail_carries_model_imageurl(client, db, auth_headers):
|
||||
asset = _seed_asset('PC-2001', 'computer')
|
||||
model = _seed_model()
|
||||
computer = Computer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(computer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
||||
resp = client.get(f'/api/computers/{computer.computerid}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['computer']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_printer_detail_carries_model_imageurl(client, db):
|
||||
def test_printer_detail_carries_model_imageurl(client, db, auth_headers):
|
||||
asset = _seed_asset('PR-2001', 'printer')
|
||||
model = _seed_model()
|
||||
printer = Printer(assetid=asset.assetid, modelnumberid=model.modelnumberid)
|
||||
_db.session.add(printer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
||||
resp = client.get(f'/api/printers/{printer.printerid}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert resp.get_json()['data']['printer']['imageurl'] == IMAGE_URL
|
||||
|
||||
|
||||
def test_detail_omits_imageurl_without_model(client, db):
|
||||
def test_detail_omits_imageurl_without_model(client, db, auth_headers):
|
||||
"""No model linked -> no imageurl key (frontend v-if shows nothing)."""
|
||||
asset = _seed_asset('PR-2002', 'printer')
|
||||
printer = Printer(assetid=asset.assetid)
|
||||
_db.session.add(printer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/printers/{printer.printerid}')
|
||||
resp = client.get(f'/api/printers/{printer.printerid}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'imageurl' not in resp.get_json()['data']['printer']
|
||||
|
||||
|
||||
def test_detail_omits_imageurl_when_model_has_no_image(client, db):
|
||||
def test_detail_omits_imageurl_when_model_has_no_image(client, db, auth_headers):
|
||||
"""Model linked but no photo -> no imageurl key."""
|
||||
asset = _seed_asset('PC-2002', 'computer')
|
||||
model = _seed_model(imageurl=None)
|
||||
@@ -95,6 +95,6 @@ def test_detail_omits_imageurl_when_model_has_no_image(client, db):
|
||||
_db.session.add(computer)
|
||||
_db.session.commit()
|
||||
|
||||
resp = client.get(f'/api/computers/{computer.computerid}')
|
||||
resp = client.get(f'/api/computers/{computer.computerid}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'imageurl' not in resp.get_json()['data']['computer']
|
||||
|
||||
@@ -18,8 +18,8 @@ def model(db):
|
||||
return model
|
||||
|
||||
|
||||
def test_supplies_meta_lists_allowed_values(client, db):
|
||||
response = client.get('/api/printers/supplies/meta')
|
||||
def test_supplies_meta_lists_allowed_values(client, db, auth_headers):
|
||||
response = client.get('/api/printers/supplies/meta', headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()['data']
|
||||
assert 'toner' in data['supplytypes']
|
||||
@@ -38,7 +38,7 @@ def test_create_and_list_model_supply(client, model, auth_headers):
|
||||
)
|
||||
assert create.status_code == 201
|
||||
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies', headers=auth_headers)
|
||||
assert listing.status_code == 200
|
||||
supplies = listing.get_json()['data']['supplies']
|
||||
assert len(supplies) == 1
|
||||
@@ -90,7 +90,7 @@ def test_update_and_delete_supply(client, model, auth_headers):
|
||||
deleted = client.delete(f'/api/printers/supplies/{supplyid}', headers=auth_headers)
|
||||
assert deleted.status_code == 200
|
||||
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies')
|
||||
listing = client.get(f'/api/printers/models/{model.modelnumberid}/supplies', headers=auth_headers)
|
||||
assert listing.get_json()['data']['supplies'] == []
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def test_listmodels_reports_supplycount(client, model, auth_headers):
|
||||
client.post(f'/api/printers/models/{model.modelnumberid}/supplies',
|
||||
json={'partnumber': 'W2020A', 'color': 'black'}, headers=auth_headers)
|
||||
|
||||
response = client.get('/api/printers/models', query_string={'search': 'C999'})
|
||||
response = client.get('/api/printers/models', query_string={'search': 'C999'}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
rows = response.get_json()['data']
|
||||
match = next(r for r in rows if r['modelnumberid'] == model.modelnumberid)
|
||||
|
||||
@@ -148,20 +148,20 @@ def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
|
||||
headers=auth_headers)
|
||||
assert stocked.status_code == 200
|
||||
|
||||
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}')
|
||||
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}', headers=auth_headers)
|
||||
assert lookup.status_code == 200
|
||||
|
||||
take = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2})
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2}, headers=auth_headers)
|
||||
assert take.status_code == 200, take.get_json()
|
||||
assert take.get_json()['data']['quantityonhand'] == 3
|
||||
|
||||
too_many = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99})
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99}, headers=auth_headers)
|
||||
assert too_many.status_code == 400
|
||||
|
||||
unknown = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1})
|
||||
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1}, headers=auth_headers)
|
||||
assert unknown.status_code == 422
|
||||
|
||||
with app.app_context():
|
||||
@@ -197,7 +197,7 @@ def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
|
||||
return client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee,
|
||||
'quantity': quantity})
|
||||
'quantity': quantity}, headers=auth_headers)
|
||||
|
||||
restock(10) # 10 on hand, threshold 5
|
||||
assert take(3).status_code == 200 # 7: above threshold, no alert
|
||||
@@ -245,7 +245,7 @@ def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
|
||||
headers=auth_headers)
|
||||
take = client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee, 'quantity': 6})
|
||||
'badge': directory_employee, 'quantity': 6}, headers=auth_headers)
|
||||
assert take.status_code == 200 # 4 on hand: crossed threshold 5
|
||||
|
||||
assert captured['to'] == ['lead@site.test', 'extra@site.test']
|
||||
@@ -260,7 +260,7 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
||||
listed = client.get('/api/printedparts/items',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert all(row['printeditemid'] != item for row in listed)
|
||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001')
|
||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001', headers=auth_headers)
|
||||
assert kiosk.status_code == 404
|
||||
|
||||
including = client.get('/api/printedparts/items?active=false',
|
||||
@@ -270,7 +270,7 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
||||
|
||||
assert client.post(f'/api/printedparts/items/{item}/restore',
|
||||
headers=auth_headers).status_code == 200
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001', headers=auth_headers).status_code == 200
|
||||
|
||||
|
||||
def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path):
|
||||
@@ -302,7 +302,7 @@ def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path
|
||||
assert [f['revision'] for f in listing] == [2, 1]
|
||||
|
||||
fileid = listing[1]['fileid']
|
||||
download = client.get(f'/api/printedparts/files/{fileid}/download')
|
||||
download = client.get(f'/api/printedparts/files/{fileid}/download', headers=auth_headers)
|
||||
assert download.status_code == 200
|
||||
assert download.data == b'solid part'
|
||||
assert 'clip_v1.stl' in download.headers['Content-Disposition']
|
||||
@@ -335,7 +335,7 @@ def test_alert_role_members_receive(client, auth_headers, app, item,
|
||||
headers=auth_headers)
|
||||
take = client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee, 'quantity': 6})
|
||||
'badge': directory_employee, 'quantity': 6}, headers=auth_headers)
|
||||
assert take.status_code == 200
|
||||
assert captured['to'] == ['crewone@site.test']
|
||||
|
||||
@@ -362,14 +362,14 @@ def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert len(searched) == 1
|
||||
|
||||
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117')
|
||||
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117', headers=auth_headers)
|
||||
assert by_tag.status_code == 200
|
||||
by_digits = client.get('/api/printedparts/kiosk/item/117')
|
||||
by_digits = client.get('/api/printedparts/kiosk/item/117', headers=auth_headers)
|
||||
assert by_digits.status_code == 200
|
||||
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'
|
||||
|
||||
|
||||
def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee):
|
||||
def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee, auth_headers):
|
||||
"""A label QR payload 'TAG|rev' resolves the item by TAG and records the
|
||||
revision on the take (traceability of which print-file rev was consumed)."""
|
||||
with app.app_context():
|
||||
@@ -380,7 +380,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
||||
iid = row.printeditemid
|
||||
|
||||
take = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1})
|
||||
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1}, headers=auth_headers)
|
||||
assert take.status_code == 200, take.get_json()
|
||||
|
||||
with app.app_context():
|
||||
@@ -391,7 +391,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
||||
|
||||
# Manual entry (no |rev) records a null revision, still resolves.
|
||||
take2 = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1})
|
||||
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1}, headers=auth_headers)
|
||||
assert take2.status_code == 200
|
||||
with app.app_context():
|
||||
last = (PrintedItemTransaction.query
|
||||
@@ -400,7 +400,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
||||
assert last.revision is None
|
||||
|
||||
|
||||
def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_employee):
|
||||
def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_employee, auth_headers):
|
||||
"""A low-stock crossing posts to the selected support team's webhook URL."""
|
||||
from unittest.mock import patch
|
||||
from shopdb.core.models import SupportTeam, Setting
|
||||
@@ -418,7 +418,7 @@ def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_empl
|
||||
with patch('shopdb.api.send_webhook') as mock_send:
|
||||
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
|
||||
resp = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2})
|
||||
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2}, headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert mock_send.called
|
||||
assert mock_send.call_args.kwargs['url'] == 'https://teams.example/3dprint'
|
||||
|
||||
@@ -299,10 +299,10 @@ def test_no_estimate_never_reaches_the_order_list():
|
||||
assert orderlist([cartridge(None)]) == []
|
||||
|
||||
|
||||
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
|
||||
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db, auth_headers):
|
||||
"""A dashboard must be told the data is missing, not shown an empty list
|
||||
that reads as 'nothing runs out soon'."""
|
||||
response = client.get('/api/printers/supplies/forecast')
|
||||
response = client.get('/api/printers/supplies/forecast', headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()['data']
|
||||
|
||||
@@ -14,6 +14,17 @@ from shopdb.core.models import Model, Vendor
|
||||
from plugins.printers.models import ModelSupply, Printer
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _signed_in(client, auth_headers):
|
||||
"""Reading needs a permission now, and these tests are about what the
|
||||
payload says rather than about who may see it. Signing the client in once
|
||||
beats threading headers through every helper in the module."""
|
||||
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||
yield
|
||||
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||
|
||||
|
||||
|
||||
LEVELS = {
|
||||
# itemid: (name, live level, history of daily readings)
|
||||
# Black is a REAL zero, which is the only thing that means empty. Magenta is
|
||||
|
||||
Reference in New Issue
Block a user