Reading anything needs a permission
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m42s

A sweep of the URL map found 165 GET routes that answered anybody, with no
token at all: the whole asset inventory, every machine, PC, printer and network
device, the USB checkout history and who is holding which drive, the staff
directory with its photographs, every core report. Guest access had been argued
about one feature-flag at a time while the front door stood open.

Each of those now carries the read permission it belongs to. That also makes
the guest role mean something: a site can open exactly the reads it chooses,
and nothing follows by accident.

Thirty-three endpoints stay public, and each is named with its reason rather
than left to be inferred: the app shell and its bootstrap, the settings
allowlist (the handler already filters an anonymous caller down to branding and
a few keys), images fetched by <img src> which carries no Authorization header,
what a display kiosk renders before anybody signs in, the parts and USB kiosks,
the client scripts that run as SYSTEM before a user session exists, and a
secure-send link whose unguessable token IS the authorisation.

tests/test_core/test_public_read_surface.py is the standing gate, the companion
to the mutating-route sweep that has guarded writes for months. A new public
read fails CI until somebody adds it to that list in front of a reviewer. Four
further reads need a login but no permission, because the handler decides from
who you are - your own user record, your own API tokens, the role catalogue the
grid renders - and the same test proves each still refuses an anonymous caller.
Verified not vacuous by ungating one route and watching the sweep name it.

The employees plugin gained employees.view; the directory is names, SSOs,
photographs and reporting lines, and it had no permission of any kind.

Around ninety tests asserted payload shapes against endpoints that answered
anybody. They are about what the answer says, not about who may ask, so they
are signed in now rather than the gate being loosened. The ones that assert an
anonymous caller is refused were left exactly as they were.
This commit is contained in:
cproudlock
2026-08-27 14:26:06 -04:00
parent 2331c03664
commit afce24379c
52 changed files with 522 additions and 225 deletions

View File

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

View File

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

View File

@@ -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."""

View File

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

View File

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

View File

@@ -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) == []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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