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