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