|
@@ -107,15 +107,17 @@ class TestSettingsScrubForApiKey:
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRceEndpointPermissions:
|
|
class TestRceEndpointPermissions:
|
|
|
- """T-Gap 2 (revised): system_command was originally gated on SETTINGS_UPDATE
|
|
|
|
|
- but that locked out kiosk operators (who hold INVENTORY_UPDATE-only keys)
|
|
|
|
|
- from the QuickMenu's Restart-Daemon / Restart-Browser / Reboot / Shutdown
|
|
|
|
|
- buttons — the only way to recover the kiosk from the kiosk itself. Risk
|
|
|
|
|
- is bounded: only the 4 named commands are accepted (no RCE), reboot and
|
|
|
|
|
- shutdown require physical-access recovery, and the same operator already
|
|
|
|
|
- controls printers + weighs spools on the same device. The /update route
|
|
|
|
|
- (full firmware upgrade) keeps SETTINGS_UPDATE because it can replace the
|
|
|
|
|
- daemon binary, which is a different threat surface."""
|
|
|
|
|
|
|
+ """T-Gap 2 (revised): system_command and /update were originally gated on
|
|
|
|
|
+ SETTINGS_UPDATE but that locked out kiosk operators (who hold
|
|
|
|
|
+ INVENTORY_UPDATE-only keys) from the QuickMenu's Restart-Daemon /
|
|
|
|
|
+ Restart-Browser / Reboot / Shutdown buttons and the Settings → Update
|
|
|
|
|
+ button — the only ways to recover or update the kiosk from the kiosk
|
|
|
|
|
+ itself. Risk is bounded the same way for both: actions are scoped to a
|
|
|
|
|
+ single SpoolBuddy device that the operator already physically controls,
|
|
|
|
|
+ daemon-replacement via /update has the same blast radius as
|
|
|
|
|
+ restart_daemon (which also replaces the running process), and the
|
|
|
|
|
+ same operator already controls printers + weighs spools on the same
|
|
|
|
|
+ device. Both routes are now on INVENTORY_UPDATE."""
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
@pytest.fixture
|
|
|
async def auth_enabled(self, db_session):
|
|
async def auth_enabled(self, db_session):
|
|
@@ -184,7 +186,7 @@ class TestRceEndpointPermissions:
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.asyncio
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.integration
|
|
|
- async def test_trigger_update_requires_settings_update(
|
|
|
|
|
|
|
+ async def test_trigger_update_accepts_inventory_update(
|
|
|
self,
|
|
self,
|
|
|
async_client: AsyncClient,
|
|
async_client: AsyncClient,
|
|
|
db_session,
|
|
db_session,
|
|
@@ -192,9 +194,19 @@ class TestRceEndpointPermissions:
|
|
|
inventory_only_api_key,
|
|
inventory_only_api_key,
|
|
|
spoolbuddy_device,
|
|
spoolbuddy_device,
|
|
|
):
|
|
):
|
|
|
|
|
+ """T-Gap 2 (revised): /update was lowered from SETTINGS_UPDATE to
|
|
|
|
|
+ INVENTORY_UPDATE so the kiosk's own Settings → Update button works.
|
|
|
|
|
+ The deny-list (SETTINGS_UPDATE in _APIKEY_DENIED_PERMISSIONS) was
|
|
|
|
|
+ returning "API keys cannot be used for administrative operations"
|
|
|
|
|
+ for any kiosk-side request to update the daemon. An inventory-only
|
|
|
|
|
+ key must NOT 403 — it should reach the device-state check (and 409
|
|
|
|
|
+ for offline device, since the fixture doesn't set last_seen).
|
|
|
|
|
+ """
|
|
|
resp = await async_client.post(
|
|
resp = await async_client.post(
|
|
|
f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/update",
|
|
f"/api/v1/spoolbuddy/devices/{spoolbuddy_device.device_id}/update",
|
|
|
json={},
|
|
json={},
|
|
|
headers={"X-API-Key": inventory_only_api_key},
|
|
headers={"X-API-Key": inventory_only_api_key},
|
|
|
)
|
|
)
|
|
|
- assert resp.status_code == 403
|
|
|
|
|
|
|
+ # Permission accepted — fails on device-state, not on auth.
|
|
|
|
|
+ assert resp.status_code == 409
|
|
|
|
|
+ assert "offline" in resp.json()["detail"].lower()
|