diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b9670d8..043575f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,31 @@ ADR-007 and ADR-002.
### Added
+- **Reading anything now needs a permission.** 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 holds which drive, the staff directory, the core
+ reports. Guest access had been argued about one feature-flag at a time while
+ the front door stood open. Every one of them is now behind the read
+ permission it belongs to - `assets.view`, `machines.view`, `printers.view`
+ and so on - which also means the guest role can open exactly the ones a site
+ chooses and nothing else.
+
+ Thirty-three endpoints stay public, each named with its reason in
+ `tests/test_core/test_public_read_surface.py`: the app shell and its
+ bootstrap, the settings allowlist, images fetched by `
` (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 token is itself
+ the authorisation. That file is a standing gate: a new public read fails CI
+ until somebody adds it to the list, in front of a reviewer. Four more 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 - and the same
+ test proves each still refuses an anonymous caller.
+
+ The employees plugin gained `employees.view`: the directory is names, SSOs,
+ photographs and reporting lines, and it had no permission at all.
+
- **A guide's sections can be ticked off while you read it.** Only checklist
items were tickable, which answered "what have I done" but not the question a
long procedure actually raises - where was I. Somebody looks away to do the
diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py
index ec355fd4..5bace5d4 100644
--- a/plugins/computers/api/routes.py
+++ b/plugins/computers/api/routes.py
@@ -17,7 +17,7 @@ computers_bp = Blueprint('computers', __name__)
# =============================================================================
@computers_bp.route('/types', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def list_computer_types():
"""List all computer types."""
page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_computer_types():
@computers_bp.route('/types/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def get_computer_type(type_id: int):
"""Get a single computer type."""
t = db.session.get(ComputerType, type_id)
@@ -145,7 +145,7 @@ def delete_computer_type(type_id: int):
# =============================================================================
@computers_bp.route('/protocols', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def list_protocols():
"""List access protocols. ?active=false includes disabled ones."""
query = AccessProtocol.query
@@ -293,7 +293,7 @@ def _sync_access_methods(comp, data):
# =============================================================================
@computers_bp.route('/display-kiosks', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def list_display_kiosks():
"""Reporting display kiosks, for the Dashboard Defaults picker.
@@ -329,7 +329,7 @@ def list_display_kiosks():
@computers_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def list_computers():
"""
List all computers with filtering and pagination.
@@ -437,7 +437,7 @@ def list_computers():
@computers_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def get_computer(computer_id: int):
"""Get a single computer with full details."""
comp = db.session.get(Computer, computer_id)
@@ -461,7 +461,7 @@ def get_computer(computer_id: int):
@computers_bp.route('/by-asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def get_computer_by_asset(asset_id: int):
"""Get computer data by asset ID."""
comp = Computer.query.filter_by(assetid=asset_id).first()
@@ -480,7 +480,7 @@ def get_computer_by_asset(asset_id: int):
@computers_bp.route('/by-hostname/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def get_computer_by_hostname(hostname: str):
"""Get computer by hostname."""
comp = Computer.query.filter_by(hostname=hostname).first()
@@ -753,7 +753,7 @@ def delete_computer(computer_id: int):
# =============================================================================
@computers_bp.route('//apps', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def get_installed_apps(computer_id: int):
"""Get all installed applications for a computer."""
comp = db.session.get(Computer, computer_id)
@@ -897,7 +897,7 @@ def report_status(computer_id: int):
# =============================================================================
@computers_bp.route('/dashboard/summary', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('computers.view')
def dashboard_summary():
"""Get computer dashboard summary data."""
# Total active computers
diff --git a/plugins/employees/api/routes.py b/plugins/employees/api/routes.py
index 92d1736a..4e9788c1 100644
--- a/plugins/employees/api/routes.py
+++ b/plugins/employees/api/routes.py
@@ -29,6 +29,7 @@ from shopdb.api import Setting
from shopdb.api import send_upload
from ..models import DirectoryEmployee
+from shopdb.api import require_permission
logger = logging.getLogger(__name__)
@@ -198,6 +199,7 @@ def _with_photo_url(employee):
@employees_bp.route('/resolve/', methods=['GET'])
+@require_permission('employees.view')
@jwt_required()
def resolve_employee(sso):
"""Resolve one SSO to a name and photo, and say HOW.
@@ -275,6 +277,7 @@ def resolve_employee(sso):
@employees_bp.route('/search', methods=['GET'])
+@require_permission('employees.view')
def search_employees():
"""
Search employees by name.
@@ -327,6 +330,7 @@ def search_employees():
@employees_bp.route('/lookup/', methods=['GET'])
+@require_permission('employees.view')
def lookup_employee(sso):
"""Look up a single employee by SSO."""
if not sso.isdigit():
@@ -370,6 +374,7 @@ def lookup_employee(sso):
@employees_bp.route('/lookup', methods=['GET'])
+@require_permission('employees.view')
def lookup_employees():
"""
Look up multiple employees by SSO list.
@@ -429,7 +434,7 @@ def lookup_employees():
# =============================================================================
@employees_bp.route('/directory', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('employees.view')
def list_directory():
"""Full self-hosted directory (for the management page)."""
guard = _require_selfhosted()
diff --git a/plugins/employees/plugin.py b/plugins/employees/plugin.py
index dfdd1c3b..9c5a18f5 100644
--- a/plugins/employees/plugin.py
+++ b/plugins/employees/plugin.py
@@ -51,6 +51,18 @@ class EmployeesPlugin(BasePlugin):
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
)
+ def get_permissions(self) -> List:
+ """Reading the staff directory.
+
+ The directory is names, SSOs, photographs and who reports to whom. It
+ was reachable with no account at all, which is how a permissions
+ question ends up answered by nobody. One read permission: the write
+ side of this plugin is an import, run from the CLI.
+ """
+ return [
+ ('employees.view', 'View the employee directory', 'employees'),
+ ]
+
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return employees_bp
diff --git a/plugins/knowledgebase/api/routes.py b/plugins/knowledgebase/api/routes.py
index 318fe024..a3dd9800 100644
--- a/plugins/knowledgebase/api/routes.py
+++ b/plugins/knowledgebase/api/routes.py
@@ -77,7 +77,7 @@ def _search_clause(search):
@knowledgebase_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('kb.view')
def list_articles():
"""List all knowledge base articles."""
page, per_page = get_pagination_params(request)
@@ -142,7 +142,7 @@ def list_articles():
@knowledgebase_bp.route('/stats', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('kb.view')
def get_stats():
"""Get knowledge base statistics."""
# Counted over the same set the list shows. A total that includes articles
@@ -158,7 +158,7 @@ def get_stats():
@knowledgebase_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('kb.view')
def get_article(link_id: int):
"""Get a single knowledge base article."""
article = db.session.get(KnowledgeBase, link_id)
diff --git a/plugins/machines/api/routes.py b/plugins/machines/api/routes.py
index 09a43176..267d84dd 100644
--- a/plugins/machines/api/routes.py
+++ b/plugins/machines/api/routes.py
@@ -17,7 +17,7 @@ machines_bp = Blueprint('machines', __name__)
# =============================================================================
@machines_bp.route('/types', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('machines.view')
def list_machine_types():
"""List all machine types."""
page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_machine_types():
@machines_bp.route('/types/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('machines.view')
def get_machine_type(type_id: int):
"""Get a single machine type."""
t = db.session.get(MachineType, type_id)
@@ -144,7 +144,7 @@ def delete_machine_type(type_id: int):
# =============================================================================
@machines_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('machines.view')
def list_machines():
"""
List all machines with filtering and pagination.
@@ -281,7 +281,7 @@ def _dualpath_partner_for(assetid):
@machines_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('machines.view')
def get_machine(machine_id: int):
"""Get a single machine item with full details."""
mach = db.session.get(Machine, machine_id)
@@ -301,7 +301,7 @@ def get_machine(machine_id: int):
@machines_bp.route('/by-asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('machines.view')
def get_machine_by_asset(asset_id: int):
"""Get machine data by asset ID."""
mach = Machine.query.filter_by(assetid=asset_id).first()
@@ -525,7 +525,7 @@ def delete_machine(machine_id: int):
# =============================================================================
@machines_bp.route('/dashboard/summary', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('machines.view')
def dashboard_summary():
"""Get machine dashboard summary data."""
# Dualpath single-machine collapse: hide the secondary bay from counts so a
diff --git a/plugins/measuringtools/api/routes.py b/plugins/measuringtools/api/routes.py
index 552115c0..77467735 100644
--- a/plugins/measuringtools/api/routes.py
+++ b/plugins/measuringtools/api/routes.py
@@ -49,7 +49,7 @@ def _merged(tool, today=None):
# =============================================================================
@measuringtools_bp.route('/types', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('measuringtools.view')
def list_types():
"""List measuring-tool types."""
page, per_page = get_pagination_params(request)
@@ -64,7 +64,7 @@ def list_types():
@measuringtools_bp.route('/types/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('measuringtools.view')
def get_type(type_id: int):
"""Get one measuring-tool type."""
tool_type = db.session.get(MeasuringToolType, type_id)
@@ -150,7 +150,7 @@ def delete_type(type_id: int):
# =============================================================================
@measuringtools_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('measuringtools.view')
def list_tools():
"""List measuring tools with filters + pagination.
@@ -208,7 +208,7 @@ def list_tools():
@measuringtools_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('measuringtools.view')
def get_tool(tool_id: int):
"""Get one measuring tool (asset core merged with the extension)."""
tool = db.session.get(MeasuringTool, tool_id)
@@ -219,7 +219,7 @@ def get_tool(tool_id: int):
@measuringtools_bp.route('/by-asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('measuringtools.view')
def get_tool_by_asset(asset_id: int):
"""Get a measuring tool by its core asset id."""
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
@@ -371,7 +371,7 @@ def delete_tool(tool_id: int):
# =============================================================================
@measuringtools_bp.route('/map-overlay', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('measuringtools.view')
def map_overlay():
"""Calibration-status overlay for active measuring tools.
@@ -402,7 +402,7 @@ def map_overlay():
# =============================================================================
@measuringtools_bp.route('/report/calibration', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('measuringtools.view')
def calibration_report():
"""Counts + lists bucketed by derived calibration status."""
today = date.today()
diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py
index b0b18fb0..83b44ce8 100644
--- a/plugins/network/api/routes.py
+++ b/plugins/network/api/routes.py
@@ -47,7 +47,7 @@ def _upsert_primary_ip(asset, ip):
# =============================================================================
@network_bp.route('/types', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def list_network_device_types():
"""List all network device types."""
page, per_page = get_pagination_params(request)
@@ -69,7 +69,7 @@ def list_network_device_types():
@network_bp.route('/types/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def get_network_device_type(type_id: int):
"""Get a single network device type."""
t = db.session.get(NetworkDeviceType, type_id)
@@ -176,7 +176,7 @@ def delete_network_device_type(type_id: int):
# =============================================================================
@network_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def list_network_devices():
"""
List all network devices with filtering and pagination.
@@ -284,7 +284,7 @@ def list_network_devices():
@network_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def get_network_device(device_id: int):
"""Get a single network device with full details."""
netdev = db.session.get(NetworkDevice, device_id)
@@ -304,7 +304,7 @@ def get_network_device(device_id: int):
@network_bp.route('/by-asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def get_network_device_by_asset(asset_id: int):
"""Get network device data by asset ID."""
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
@@ -324,7 +324,7 @@ def get_network_device_by_asset(asset_id: int):
@network_bp.route('/by-hostname/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def get_network_device_by_hostname(hostname: str):
"""Get network device by hostname."""
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
@@ -583,7 +583,7 @@ def delete_network_device(device_id: int):
# =============================================================================
@network_bp.route('/dashboard/summary', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def dashboard_summary():
"""Get network device dashboard summary data."""
# Total active network devices
@@ -631,7 +631,7 @@ def dashboard_summary():
# =============================================================================
@network_bp.route('/vlans', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def list_vlans():
"""List all VLANs with filtering and pagination."""
page, per_page = get_pagination_params(request)
@@ -664,7 +664,7 @@ def list_vlans():
@network_bp.route('/vlans/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def get_vlan(vlan_id: int):
"""Get a single VLAN with its subnets."""
vlan = db.session.get(VLAN, vlan_id)
@@ -806,7 +806,7 @@ def delete_vlan(vlan_id: int):
# =============================================================================
@network_bp.route('/subnets', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def list_subnets():
"""List all subnets with filtering and pagination."""
page, per_page = get_pagination_params(request)
@@ -847,7 +847,7 @@ def list_subnets():
@network_bp.route('/subnets/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('network.view')
def get_subnet(subnet_id: int):
"""Get a single subnet plus the network devices whose primary IP falls in
its CIDR range."""
diff --git a/plugins/notifications/api/routes.py b/plugins/notifications/api/routes.py
index 1c900428..a2db5d9e 100644
--- a/plugins/notifications/api/routes.py
+++ b/plugins/notifications/api/routes.py
@@ -239,6 +239,7 @@ def _employee_picture(sso):
# =============================================================================
@notifications_bp.route('/types', methods=['GET'])
+@require_permission('notifications.view')
def list_notification_types():
"""List all notification types."""
page, per_page = get_pagination_params(request)
@@ -333,6 +334,7 @@ def update_notification_type(type_id: int):
# =============================================================================
@notifications_bp.route('', methods=['GET'])
+@require_permission('notifications.view')
def list_notifications():
"""
List all notifications with filtering and pagination.
@@ -388,6 +390,7 @@ def list_notifications():
@notifications_bp.route('/', methods=['GET'])
+@require_permission('notifications.view')
def get_notification(notification_id: int):
"""Get a single notification."""
n = db.session.get(Notification, notification_id)
@@ -551,6 +554,7 @@ def delete_notification(notification_id: int):
# =============================================================================
@notifications_bp.route('/active', methods=['GET'])
+@require_permission('notifications.view')
def get_active_notifications():
"""
Get currently active notifications for display.
@@ -581,6 +585,7 @@ def get_active_notifications():
@notifications_bp.route('/calendar', methods=['GET'])
+@require_permission('notifications.view')
def get_calendar_events():
"""
Get notifications in FullCalendar event format.
@@ -619,6 +624,7 @@ def get_calendar_events():
@notifications_bp.route('/dashboard/summary', methods=['GET'])
+@require_permission('notifications.view')
def dashboard_summary():
"""Get notifications dashboard summary."""
now = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -869,6 +875,7 @@ def get_shopfloor_notifications():
@notifications_bp.route('/dashboard/active', methods=['GET'])
+@require_permission('notifications.view')
@jwt_required()
def dashboard_active():
"""Notifications currently showing on the floor.
diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py
index 42ffe3cf..122e0c1d 100644
--- a/plugins/printedparts/api/routes.py
+++ b/plugins/printedparts/api/routes.py
@@ -542,7 +542,7 @@ def _csv_response(rows, columns, filename):
@printedparts_bp.route('/reports/stock', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printedparts.view')
def report_stock():
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
@@ -576,7 +576,7 @@ def report_stock():
@printedparts_bp.route('/reports/consumption', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printedparts.view')
def report_consumption():
"""Takes per item, optionally bounded by ?days= (default 30)."""
days = request.args.get('days', 30, type=int)
@@ -604,7 +604,7 @@ def report_consumption():
@printedparts_bp.route('/reports/by-person', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printedparts.view')
def report_by_person():
"""Takes grouped by employee, optionally bounded by ?days= (default 30)."""
days = request.args.get('days', 30, type=int)
@@ -722,7 +722,7 @@ def upload_item_file(item_id: int):
@printedparts_bp.route('/files//download', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printedparts.view')
def download_item_file(file_id: int):
"""Download a revision under its original filename."""
record = db.session.get(PrintedItemFile, file_id)
diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py
index 60760a42..6e3fe938 100644
--- a/plugins/printers/api/asset_routes.py
+++ b/plugins/printers/api/asset_routes.py
@@ -30,7 +30,7 @@ printers_asset_bp = Blueprint('printers_asset', __name__)
# =============================================================================
@printers_asset_bp.route('/types', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def list_printer_types():
"""List all printer types."""
page, per_page = get_pagination_params(request)
@@ -52,7 +52,7 @@ def list_printer_types():
@printers_asset_bp.route('/types/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def get_printer_type(type_id: int):
"""Get a single printer type."""
t = db.session.get(PrinterType, type_id)
@@ -147,7 +147,7 @@ def delete_printer_type(type_id: int):
# =============================================================================
@printers_asset_bp.route('/drivers', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def list_drivers():
"""List printer drivers. ?active=false includes inactive ones."""
query = PrinterDriver.query
@@ -207,7 +207,7 @@ def delete_driver(driver_id):
# =============================================================================
@printers_asset_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def list_printers():
"""
List all printers with filtering and pagination.
@@ -964,7 +964,7 @@ def printers_for_host(hostname: str):
@printers_asset_bp.route('/assignments/for-asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def get_asset_printer_assignment(asset_id: int):
"""What THIS asset is assigned, without inheritance.
@@ -1778,7 +1778,7 @@ def seed_assignment_from_observed(asset_id: int):
@printers_asset_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def get_printer(printer_id: int):
"""Get a single printer with full details."""
printer = db.session.get(Printer, printer_id)
@@ -1811,7 +1811,7 @@ def get_printer(printer_id: int):
@printers_asset_bp.route('/by-asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def get_printer_by_asset(asset_id: int):
"""Get printer data by asset ID."""
printer = Printer.query.filter_by(assetid=asset_id).first()
@@ -2043,7 +2043,7 @@ def delete_printer(printer_id: int):
# =============================================================================
@printers_asset_bp.route('//supplies', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def get_printer_supplies(printer_id: int):
"""Get supply levels from Zabbix (real-time lookup)."""
printer = db.session.get(Printer, printer_id)
@@ -2238,7 +2238,7 @@ def _get_low_supplies_data():
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def low_supplies():
"""Get printers with low or critical supply levels."""
data = _get_low_supplies_data()
@@ -2246,7 +2246,7 @@ def low_supplies():
@printers_asset_bp.route('/lookup', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def printer_lookup():
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
@@ -2306,7 +2306,7 @@ def refresh_supplies_cache():
# =============================================================================
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def dashboard_summary():
"""Get printer dashboard summary data."""
# Total active printers
@@ -2380,7 +2380,7 @@ def _validate_supply_payload(data):
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def supplies_meta():
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
return success_response({
@@ -2391,7 +2391,7 @@ def supplies_meta():
@printers_asset_bp.route('/models', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def list_supply_models():
"""List models with a supply count, for the supply-management picker.
@@ -2450,7 +2450,7 @@ def list_supply_models():
@printers_asset_bp.route('/models//supplies', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def list_model_supplies(modelnumberid: int):
"""List all supplies mapped to a model."""
model = db.session.get(Model, modelnumberid)
@@ -2667,7 +2667,7 @@ def dashboard_supplies():
# =============================================================================
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('printers.view')
def supplies_forecast():
"""Days-to-empty and replacement counts per printer.
diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py
index e17546c6..3bb15b2f 100644
--- a/plugins/usb/api/routes.py
+++ b/plugins/usb/api/routes.py
@@ -257,7 +257,7 @@ def _log_to_dict(cur, row):
# =============================================================================
@usb_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('usb.view')
def list_usb_devices():
"""List USB devices with checkout status.
@@ -306,7 +306,7 @@ def list_usb_devices():
@usb_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('usb.view')
def get_usb_device(device_id):
"""Get a single device plus its last 20 check-in/out log rows."""
if _usb_selfhosted():
@@ -595,7 +595,7 @@ def checkin_device(device_id):
# =============================================================================
@usb_bp.route('//history', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('usb.view')
def get_device_history(device_id):
"""Paginated check-in/out log for one device."""
if _usb_selfhosted():
@@ -625,7 +625,7 @@ def get_device_history(device_id):
@usb_bp.route('/checkouts', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('usb.view')
def list_all_checkouts():
"""List check-out log rows.
@@ -676,7 +676,7 @@ def list_all_checkouts():
@usb_bp.route('/checkouts/active', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('usb.view')
def list_active_checkouts():
"""List the latest check-out log row for every currently checked-out device.
diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py
index 95bb8912..a2a0218e 100644
--- a/plugins/warranty/api/routes.py
+++ b/plugins/warranty/api/routes.py
@@ -140,7 +140,7 @@ def _apply_links(warranty, assetids):
# =============================================================================
@warranty_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('warranty.view')
def list_warranties():
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
query = Warranty.query
@@ -174,7 +174,7 @@ def list_warranties():
@warranty_bp.route('/asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('warranty.view')
def warranties_for_asset(assetid):
"""Warranties covering one asset (for the asset-detail panel)."""
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
@@ -188,7 +188,7 @@ def warranties_for_asset(assetid):
@warranty_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('warranty.view')
def get_warranty(warrantyid):
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
@@ -395,7 +395,7 @@ def sync_dell():
# =============================================================================
@warranty_bp.route('/report', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('warranty.view')
def warranty_report():
"""Counts + lists bucketed by derived status."""
today = date.today()
diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py
index 2416fe93..3ddd974e 100644
--- a/shopdb/core/api/applications.py
+++ b/shopdb/core/api/applications.py
@@ -118,7 +118,7 @@ applications_bp = Blueprint('applications', __name__)
@applications_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('applications.view')
def list_applications():
"""List all applications."""
page, per_page = get_pagination_params(request)
@@ -163,7 +163,7 @@ def list_applications():
@applications_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('applications.view')
def get_application(app_id: int):
"""Get a single application with details."""
app = db.session.get(Application, app_id)
@@ -288,7 +288,7 @@ def delete_application(app_id: int):
# ---- Versions ----
@applications_bp.route('//versions', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('applications.view')
def list_versions(app_id: int):
"""List all versions for an application."""
app = db.session.get(Application, app_id)
@@ -337,7 +337,7 @@ def create_version(app_id: int):
# ---- Computers with this app installed ----
@applications_bp.route('//installed', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('applications.view')
def list_installed_machines(app_id: int):
"""List all computers that have this application installed."""
app = db.session.get(Application, app_id)
@@ -376,7 +376,7 @@ def list_installed_machines(app_id: int):
# ---- Installed Apps (per computer) ----
@applications_bp.route('/machines/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('applications.view')
def list_machine_applications(machine_id: int):
"""List all applications installed on a computer."""
models, err = _require_computer_models()
@@ -541,6 +541,7 @@ def upload_application_image(app_id: int):
@applications_bp.route('/image/', methods=['GET'])
+@require_permission('applications.view')
def serve_application_image(filename):
"""Serve an uploaded application image (public - lists and tiles read it)."""
return send_upload(_appimage_dir(), filename)
diff --git a/shopdb/core/api/assets.py b/shopdb/core/api/assets.py
index 4064619f..006c16d5 100644
--- a/shopdb/core/api/assets.py
+++ b/shopdb/core/api/assets.py
@@ -25,7 +25,7 @@ assets_bp = Blueprint('assets', __name__)
# =============================================================================
@assets_bp.route('/types', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_asset_types():
"""List all asset types."""
page, per_page = get_pagination_params(request)
@@ -44,7 +44,7 @@ def list_asset_types():
@assets_bp.route('/types/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_asset_type(type_id: int):
"""Get a single asset type."""
t = db.session.get(AssetType, type_id)
@@ -111,7 +111,7 @@ def update_asset_type(type_id: int):
# =============================================================================
@assets_bp.route('/statuses', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_asset_statuses():
"""List all asset statuses."""
page, per_page = get_pagination_params(request)
@@ -130,7 +130,7 @@ def list_asset_statuses():
@assets_bp.route('/statuses/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_asset_status(status_id: int):
"""Get a single asset status."""
s = db.session.get(AssetStatus, status_id)
@@ -228,7 +228,7 @@ def delete_asset_status(status_id: int):
# =============================================================================
@assets_bp.route('/relationshiptypes', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_relationship_types():
"""List all asset relationship types."""
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
@@ -321,7 +321,7 @@ def delete_relationship_type(type_id: int):
# =============================================================================
@assets_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_assets():
"""
List all assets with filtering and pagination.
@@ -407,7 +407,7 @@ def list_assets():
@assets_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_asset(asset_id: int):
"""
Get a single asset with full details.
@@ -551,7 +551,7 @@ def delete_asset(asset_id: int):
@assets_bp.route('/lookup/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def lookup_asset_by_number(assetnumber: str):
"""
Look up an asset by its asset number.
@@ -672,7 +672,7 @@ def propagate_relationship(rel):
@assets_bp.route('//relationships', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_asset_relationships(asset_id: int):
"""
Get all relationships for an asset.
@@ -904,7 +904,7 @@ def delete_asset_relationship(rel_id: int):
# =============================================================================
@assets_bp.route('/map', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_assets_map():
"""
Get all assets with map positions for unified floor map display.
@@ -1226,7 +1226,7 @@ def get_assets_map():
@assets_bp.route('//communications', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_asset_communications(asset_id: int):
"""Get all communications for an asset."""
from shopdb.core.models import Communication
diff --git a/shopdb/core/api/businessunits.py b/shopdb/core/api/businessunits.py
index 6baf9b00..f4324221 100644
--- a/shopdb/core/api/businessunits.py
+++ b/shopdb/core/api/businessunits.py
@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
+from shopdb.utils.authz import require_permission
businessunits_bp = Blueprint('businessunits', __name__)
@@ -51,7 +52,7 @@ def list_businessunits():
@businessunits_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_businessunit(bu_id: int):
"""Get a single business unit."""
bu = db.session.get(BusinessUnit, bu_id)
diff --git a/shopdb/core/api/customfields.py b/shopdb/core/api/customfields.py
index 3032bf0e..da8ad239 100644
--- a/shopdb/core/api/customfields.py
+++ b/shopdb/core/api/customfields.py
@@ -15,6 +15,7 @@ from shopdb.utils.responses import (
ErrorCodes
)
from shopdb.utils.authz import require_role
+from shopdb.utils.authz import require_permission
customfields_bp = Blueprint('customfields', __name__)
@@ -45,7 +46,7 @@ def _normalize_options(raw):
# =============================================================================
@customfields_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_fields():
"""List custom-field definitions. Filter by ?assettypeid=. ?active=false
includes inactive ones."""
@@ -171,7 +172,7 @@ def _fields_with_values(asset):
@customfields_bp.route('/asset/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_asset_fields(assetid):
"""Active custom fields for an asset's type, merged with its values."""
asset = db.session.get(Asset, assetid)
diff --git a/shopdb/core/api/dashboard.py b/shopdb/core/api/dashboard.py
index 7c36b6d3..5a3a525d 100644
--- a/shopdb/core/api/dashboard.py
+++ b/shopdb/core/api/dashboard.py
@@ -6,6 +6,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, AssetStatus
from shopdb.utils.responses import success_response
+from shopdb.utils.authz import require_permission
dashboard_bp = Blueprint('dashboard', __name__)
@@ -65,7 +66,7 @@ def _countedassets():
@dashboard_bp.route('/summary', methods=['GET'])
@dashboard_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_dashboard():
"""Get dashboard summary data (asset-based)."""
machine_count = _count_by_type('machine')
@@ -121,7 +122,7 @@ def get_dashboard():
@dashboard_bp.route('/stats', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_stats():
"""Get detailed statistics by asset type."""
type_counts = db.session.query(
@@ -211,7 +212,7 @@ def get_navigation():
@dashboard_bp.route('/widgets', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_widgets():
"""Aggregate dashboard widget definitions from all enabled plugins.
diff --git a/shopdb/core/api/dashboarddefaults.py b/shopdb/core/api/dashboarddefaults.py
index e8ee5f4e..3f1b08cb 100644
--- a/shopdb/core/api/dashboarddefaults.py
+++ b/shopdb/core/api/dashboarddefaults.py
@@ -13,6 +13,7 @@ from shopdb.core.models.dashboarddefault import (
DISPLAY_ROLES, DISPLAY_ROLE_PATHS, normalize_display_role)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
+from shopdb.utils.authz import require_permission
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
@@ -102,7 +103,7 @@ def display_role():
@dashboarddefaults_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('settings.view')
def list_defaults():
"""List all visitor-IP -> business-unit mappings."""
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(
diff --git a/shopdb/core/api/locations.py b/shopdb/core/api/locations.py
index dd4cf56b..dfd73eff 100644
--- a/shopdb/core/api/locations.py
+++ b/shopdb/core/api/locations.py
@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
+from shopdb.utils.authz import require_permission
locations_bp = Blueprint('locations', __name__)
@@ -30,7 +31,7 @@ def _loc_type_dict(t):
@locations_bp.route('/types', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_location_types():
"""List location types. ?active=false includes inactive ones."""
query = LocationType.query
@@ -101,7 +102,7 @@ def delete_location_type(type_id):
@locations_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_locations():
"""List all locations."""
page, per_page = get_pagination_params(request)
@@ -132,7 +133,7 @@ def list_locations():
@locations_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_location(location_id: int):
"""Get a single location."""
loc = db.session.get(Location, location_id)
diff --git a/shopdb/core/api/maplevels.py b/shopdb/core/api/maplevels.py
index 061732ee..78bd7f06 100644
--- a/shopdb/core/api/maplevels.py
+++ b/shopdb/core/api/maplevels.py
@@ -20,6 +20,7 @@ from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
from shopdb.api import send_upload
from shopdb.utils.imagesize import image_size
+from shopdb.utils.authz import require_permission
maplevels_bp = Blueprint('maplevels', __name__)
@@ -38,6 +39,7 @@ def _blueprint_dir():
# =============================================================================
@maplevels_bp.route('', methods=['GET'])
+@require_permission('assets.view')
def list_levels():
"""Every active level, grouped by building, in display order.
@@ -72,6 +74,7 @@ def list_levels():
@maplevels_bp.route('/', methods=['GET'])
+@require_permission('assets.view')
def get_level(levelid):
level = db.session.get(MapLevel, levelid)
if not level or not level.isactive:
@@ -81,6 +84,7 @@ def get_level(levelid):
@maplevels_bp.route('//blueprint/', methods=['GET'])
+@require_permission('assets.view')
def serve_blueprint(filename, levelid=None):
"""Serve a level's blueprint image. Public, for the same reason as above."""
return send_upload(_blueprint_dir(), filename)
diff --git a/shopdb/core/api/models.py b/shopdb/core/api/models.py
index 7eac4806..a788fc26 100644
--- a/shopdb/core/api/models.py
+++ b/shopdb/core/api/models.py
@@ -20,6 +20,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.uploads import send_upload
+from shopdb.utils.authz import require_permission
models_bp = Blueprint('models', __name__)
@@ -39,7 +40,7 @@ def _modelimage_dir():
@models_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_models():
"""List all vendor catalog models."""
page, per_page = get_pagination_params(request)
@@ -78,7 +79,7 @@ def list_models():
@models_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_model(model_id: int):
"""Get a single model."""
m = db.session.get(Model, model_id)
@@ -227,6 +228,7 @@ def upload_model_image(model_id: int):
@models_bp.route('/image/', methods=['GET'])
+@require_permission('assets.view')
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_upload(_modelimage_dir(), filename)
diff --git a/shopdb/core/api/modeltypes.py b/shopdb/core/api/modeltypes.py
index 71def4c3..52f5b2fe 100644
--- a/shopdb/core/api/modeltypes.py
+++ b/shopdb/core/api/modeltypes.py
@@ -19,12 +19,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
+from shopdb.utils.authz import require_permission
modeltypes_bp = Blueprint('modeltypes', __name__)
@modeltypes_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_modeltypes():
"""List all model types with optional filtering."""
page, per_page = get_pagination_params(request)
@@ -53,7 +54,7 @@ def list_modeltypes():
@modeltypes_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_modeltype(type_id: int):
"""Get a single model type."""
mt = db.session.get(ModelType, type_id)
diff --git a/shopdb/core/api/operatingsystems.py b/shopdb/core/api/operatingsystems.py
index f6fa4d49..b3e63f44 100644
--- a/shopdb/core/api/operatingsystems.py
+++ b/shopdb/core/api/operatingsystems.py
@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
+from shopdb.utils.authz import require_permission
operatingsystems_bp = Blueprint('operatingsystems', __name__)
@operatingsystems_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_operatingsystems():
"""List all operating systems."""
page, per_page = get_pagination_params(request)
@@ -49,7 +50,7 @@ def list_operatingsystems():
@operatingsystems_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_operatingsystem(os_id: int):
"""Get a single operating system."""
os = db.session.get(OperatingSystem, os_id)
diff --git a/shopdb/core/api/plugins.py b/shopdb/core/api/plugins.py
index cbd74b2d..4d32b5af 100644
--- a/shopdb/core/api/plugins.py
+++ b/shopdb/core/api/plugins.py
@@ -6,12 +6,13 @@ from flask_jwt_extended import jwt_required
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_role
+from shopdb.utils.authz import require_permission
plugins_bp = Blueprint('plugins', __name__)
@plugins_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('settings.view')
def list_plugins():
"""List all discovered plugins (enabled or not) with their metadata and
the framework contract version."""
diff --git a/shopdb/core/api/pluginui.py b/shopdb/core/api/pluginui.py
index 90a41e8b..1173f4b7 100644
--- a/shopdb/core/api/pluginui.py
+++ b/shopdb/core/api/pluginui.py
@@ -15,6 +15,7 @@ from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset
from shopdb.utils.responses import success_response, error_response, ErrorCodes
+from shopdb.utils.authz import require_permission
pluginui_bp = Blueprint('pluginui', __name__)
@@ -47,7 +48,7 @@ def _collect(hookname):
@pluginui_bp.route('/settings-cards', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def settings_cards():
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
@@ -60,7 +61,7 @@ def settings_cards():
@pluginui_bp.route('/asset-panels', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def asset_panels():
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
@@ -86,7 +87,7 @@ def asset_panels():
@pluginui_bp.route('/map-overlays', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def map_overlays():
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
@@ -98,7 +99,7 @@ def map_overlays():
@pluginui_bp.route('/asset-presentation', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def asset_presentation():
"""Merge enabled plugins' asset-type presentation entries (hook).
diff --git a/shopdb/core/api/reports.py b/shopdb/core/api/reports.py
index 1963dc97..0b523580 100644
--- a/shopdb/core/api/reports.py
+++ b/shopdb/core/api/reports.py
@@ -32,7 +32,7 @@ def generate_csv(data: list, columns: list) -> str:
# =============================================================================
@reports_bp.route('/machines-by-type', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('reports.view')
def machines_by_type():
"""
Report: Machine count grouped by machine type.
@@ -107,7 +107,7 @@ def machines_by_type():
# =============================================================================
@reports_bp.route('/assets-by-status', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('reports.view')
def assets_by_status():
"""
Report: Asset count grouped by status.
@@ -166,7 +166,7 @@ def assets_by_status():
# =============================================================================
@reports_bp.route('/kb-popularity', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('reports.view')
def kb_popularity():
"""
Report: Most clicked knowledge base articles.
@@ -223,7 +223,7 @@ def kb_popularity():
# =============================================================================
@reports_bp.route('/software-compliance', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('reports.view')
def software_compliance():
"""
Report: Required applications vs installed (per PC).
@@ -342,7 +342,7 @@ def software_compliance():
# =============================================================================
@reports_bp.route('/asset-inventory', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('reports.view')
def asset_inventory():
"""
Report: Complete asset inventory summary.
@@ -456,7 +456,7 @@ def asset_inventory():
# =============================================================================
@reports_bp.route('/pc-relationships', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('reports.view')
def pc_relationships():
"""
Report: PCs with relationships to shop floor machines.
@@ -573,7 +573,7 @@ def email_report():
@reports_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('reports.view')
def list_reports():
"""List all available reports."""
reports = [
diff --git a/shopdb/core/api/search.py b/shopdb/core/api/search.py
index 51c06371..11218a9b 100644
--- a/shopdb/core/api/search.py
+++ b/shopdb/core/api/search.py
@@ -18,6 +18,7 @@ from shopdb.core.models import (
from shopdb.core.api.settings import get_cached_settings
from shopdb.core.services.spellfix import Vocabulary
from shopdb.utils.responses import success_response
+from shopdb.utils.authz import require_permission
logger = logging.getLogger(__name__)
@@ -1161,7 +1162,7 @@ def _suggestion_for(query):
@search_bp.route('/suggest', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def search_suggest():
"""A better spelling for a query that found nothing, or null.
@@ -1185,7 +1186,7 @@ def search_suggest():
@search_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def global_search():
"""
Global search across multiple entity types.
diff --git a/shopdb/core/api/supportteams.py b/shopdb/core/api/supportteams.py
index d6409bb9..b383e427 100644
--- a/shopdb/core/api/supportteams.py
+++ b/shopdb/core/api/supportteams.py
@@ -14,12 +14,13 @@ from shopdb.utils.responses import (
)
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
+from shopdb.utils.authz import require_permission
supportteams_bp = Blueprint('supportteams', __name__)
@supportteams_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_support_teams():
"""List support teams (with contacts). ?active and ?teamname filters."""
query = SupportTeam.query
@@ -36,7 +37,7 @@ def list_support_teams():
@supportteams_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_support_team(team_id: int):
"""Get a single support team with its contacts."""
team = db.session.get(SupportTeam, team_id)
diff --git a/shopdb/core/api/vendors.py b/shopdb/core/api/vendors.py
index 6c1104a9..0c0c3516 100644
--- a/shopdb/core/api/vendors.py
+++ b/shopdb/core/api/vendors.py
@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.authz import require_role
from shopdb.utils.import_mode import apply_import_timestamps
+from shopdb.utils.authz import require_permission
vendors_bp = Blueprint('vendors', __name__)
@vendors_bp.route('', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def list_vendors():
"""List all vendors."""
page, per_page = get_pagination_params(request)
@@ -46,7 +47,7 @@ def list_vendors():
@vendors_bp.route('/', methods=['GET'])
-@jwt_required(optional=True)
+@require_permission('assets.view')
def get_vendor(vendor_id: int):
"""Get a single vendor."""
v = db.session.get(Vendor, vendor_id)
diff --git a/shopdb/utils/authz.py b/shopdb/utils/authz.py
index 91993757..2a267643 100644
--- a/shopdb/utils/authz.py
+++ b/shopdb/utils/authz.py
@@ -133,6 +133,9 @@ def require_permission(permission_name: str):
http_code=403
)
return view_func(*args, **kwargs)
+ # Named so a test can sweep the URL map and prove every read is gated;
+ # a decorator is invisible from the outside otherwise.
+ wrapper._requires_permission = (permission_name,)
return wrapper
return decorator
@@ -180,6 +183,7 @@ def require_any_permission(*permission_names):
http_code=403
)
return view_func(*args, **kwargs)
+ wrapper._requires_permission = tuple(permission_names)
return wrapper
return decorator
@@ -215,5 +219,7 @@ def require_role(rolename: str):
http_code=403
)
return view_func(*args, **kwargs)
+ # A role gate is a gate too, as far as the read sweep is concerned.
+ wrapper._requires_permission = ('role:' + rolename,)
return wrapper
return decorator
diff --git a/tests/test_core/test_application_uploads.py b/tests/test_core/test_application_uploads.py
index f88dac94..c7e07cc0 100644
--- a/tests/test_core/test_application_uploads.py
+++ b/tests/test_core/test_application_uploads.py
@@ -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'
diff --git a/tests/test_core/test_display_kiosks.py b/tests/test_core/test_display_kiosks.py
index 1ca09425..85befa02 100644
--- a/tests/test_core/test_display_kiosks.py
+++ b/tests/test_core/test_display_kiosks.py
@@ -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'] == []
diff --git a/tests/test_core/test_employees.py b/tests/test_core/test_employees.py
index aacb64ab..26e80de1 100644
--- a/tests/test_core/test_employees.py
+++ b/tests/test_core/test_employees.py
@@ -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'
diff --git a/tests/test_core/test_model_image.py b/tests/test_core/test_model_image.py
index 5ed8a996..8b4ef750 100644
--- a/tests/test_core/test_model_image.py
+++ b/tests/test_core/test_model_image.py
@@ -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
diff --git a/tests/test_core/test_public_read_surface.py b/tests/test_core/test_public_read_surface.py
new file mode 100644
index 00000000..8b4f552a
--- /dev/null
+++ b/tests/test_core/test_public_read_surface.py
@@ -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
, 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(''.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))
diff --git a/tests/test_core/test_relationship_directional.py b/tests/test_core/test_relationship_directional.py
index ce59deb6..4067b531 100644
--- a/tests/test_core/test_relationship_directional.py
+++ b/tests/test_core/test_relationship_directional.py
@@ -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']
diff --git a/tests/test_core/test_search_coverage.py b/tests/test_core/test_search_coverage.py
index d80fd3cb..a7d70eec 100644
--- a/tests/test_core/test_search_coverage.py
+++ b/tests/test_core/test_search_coverage.py
@@ -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}')
diff --git a/tests/test_core/test_security_headers.py b/tests/test_core/test_security_headers.py
index 68974e3f..b20e2ae2 100644
--- a/tests/test_core/test_security_headers.py
+++ b/tests/test_core/test_security_headers.py
@@ -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'
diff --git a/tests/test_plugins/test_dualpath_single.py b/tests/test_plugins/test_dualpath_single.py
index 551d4987..f8c128d7 100644
--- a/tests/test_plugins/test_dualpath_single.py
+++ b/tests/test_plugins/test_dualpath_single.py
@@ -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
diff --git a/tests/test_plugins/test_employee_photo.py b/tests/test_plugins/test_employee_photo.py
index 7de0142a..664c7fbe 100644
--- a/tests/test_plugins/test_employee_photo.py
+++ b/tests/test_plugins/test_employee_photo.py
@@ -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'
diff --git a/tests/test_plugins/test_knowledgebase_search_words.py b/tests/test_plugins/test_knowledgebase_search_words.py
index e26e15f6..341aad75 100644
--- a/tests/test_plugins/test_knowledgebase_search_words.py
+++ b/tests/test_plugins/test_knowledgebase_search_words.py
@@ -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."""
diff --git a/tests/test_plugins/test_knowledgebase_visibility.py b/tests/test_plugins/test_knowledgebase_visibility.py
index 3d8870a4..f6412586 100644
--- a/tests/test_plugins/test_knowledgebase_visibility.py
+++ b/tests/test_plugins/test_knowledgebase_visibility.py
@@ -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
diff --git a/tests/test_plugins/test_list_search_type_columns.py b/tests/test_plugins/test_list_search_type_columns.py
index 1b7f7e94..71ad6c8d 100644
--- a/tests/test_plugins/test_list_search_type_columns.py
+++ b/tests/test_plugins/test_list_search_type_columns.py
@@ -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']
diff --git a/tests/test_plugins/test_machines_search.py b/tests/test_plugins/test_machines_search.py
index 1ef0537b..800c13f7 100644
--- a/tests/test_plugins/test_machines_search.py
+++ b/tests/test_plugins/test_machines_search.py
@@ -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) == []
diff --git a/tests/test_plugins/test_measuringtools.py b/tests/test_plugins/test_measuringtools.py
index cee57943..b4fa210a 100644
--- a/tests/test_plugins/test_measuringtools.py
+++ b/tests/test_plugins/test_measuringtools.py
@@ -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.
diff --git a/tests/test_plugins/test_model_image_in_detail.py b/tests/test_plugins/test_model_image_in_detail.py
index 4b49f836..e31182e5 100644
--- a/tests/test_plugins/test_model_image_in_detail.py
+++ b/tests/test_plugins/test_model_image_in_detail.py
@@ -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']
diff --git a/tests/test_plugins/test_modelsupplies.py b/tests/test_plugins/test_modelsupplies.py
index f83d6e76..a0062862 100644
--- a/tests/test_plugins/test_modelsupplies.py
+++ b/tests/test_plugins/test_modelsupplies.py
@@ -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)
diff --git a/tests/test_plugins/test_printedparts_ledger.py b/tests/test_plugins/test_printedparts_ledger.py
index 0257ba03..993c8578 100644
--- a/tests/test_plugins/test_printedparts_ledger.py
+++ b/tests/test_plugins/test_printedparts_ledger.py
@@ -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'
diff --git a/tests/test_plugins/test_supply_history.py b/tests/test_plugins/test_supply_history.py
index 00705be8..654f8c36 100644
--- a/tests/test_plugins/test_supply_history.py
+++ b/tests/test_plugins/test_supply_history.py
@@ -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']
diff --git a/tests/test_plugins/test_toner_forecast_report.py b/tests/test_plugins/test_toner_forecast_report.py
index 55cadbb5..36000e13 100644
--- a/tests/test_plugins/test_toner_forecast_report.py
+++ b/tests/test_plugins/test_toner_forecast_report.py
@@ -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