mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 14:41:24 +02:00
Printing a multi-plate file in different quantities per plate meant queueing each plate separately and tracking the counts by hand: one shared Quantity field cannot say "plate 1 once, plate 2 twice, plate 3 three times". Each selected plate now carries its own quantity, and the submission becomes an order on a new Batches tab. The point is the distinction the old flat batch could not express. print_batch_plates stores how many runs of each plate were wanted, separately from what was queued, so a run that fails, is cancelled or is skipped does not satisfy a target -- the order goes on saying it owes a print instead of quietly under-delivering. Queue remaining re-queues exactly what is missing, for the whole order or one plate, by cloning the most recent item for that plate: that inherits the printer or model target, AMS mapping, filament overrides and print options along with the validation they already passed, rather than re-serialising twenty fields through a template that would drift from the model the first time someone adds a column. Clones append to the end of the relevant printer's queue and take the same advisory lock the add-to-queue route does; positions are per-printer sequences, not global. Cost is measured, not estimated. print_log_entries gains queue_item_id, set where the queue item is already in scope, so each run's material and energy are attributed through the item that produced them -- an unrelated reprint of the same archive never lands in an order's total, and a multi-plate order gets each plate's own cost rather than the whole file's via the plate-scoped estimate from #2614. Before any run has completed there is no honest figure, so cost reads as unknown instead of a fabricated 0.00. The Batches tab wires up GET /queue/batches, which has been unreferenced since the batch MVP shipped, along with six locale keys that were translated and never used. It is a separate tab because an order outlives the queue that produced it: once its runs finish they leave the active queue, so Queue and History each hold half the picture. completed was not a reachable status before now, so every batch created since April is still marked active however long ago its last print finished -- 73 of them on the development install. A startup pass closes out the finished ones: those whose runs all completed become completed, and groupings whose items were all cancelled become cancelled, which is what they are. Not applied to orders, which state their intent independently of their runs and still owe the work. Only batches with nothing queued or printing are considered, and repeating the pass also catches an order whose last run landed while the process was down. Batches with neither items nor targets are no longer listed at all -- empty shells left when a grouping's items went with their source archive. Dispatch applies the same source-file gates as POST /queue/. It creates queue items, so without them it would be a weaker door to the same outcome; the archive and library-file checks move into shared helpers so a third route cannot drift from them.
1910 lines
75 KiB
Python
1910 lines
75 KiB
Python
"""Integration tests for ownership-based permission system.
|
|
|
|
Tests the ownership permission model where users can have:
|
|
- *_all permissions: can modify any item
|
|
- *_own permissions: can only modify items they created
|
|
- Ownerless items (created_by_id = null) require *_all permission
|
|
"""
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
|
|
class TestOwnershipPermissionsSetup:
|
|
"""Helper fixture class for ownership permission tests."""
|
|
|
|
@pytest.fixture
|
|
async def auth_setup(self, async_client: AsyncClient):
|
|
"""Setup auth with admin, create test users with different permission levels."""
|
|
# Enable auth with admin user
|
|
await async_client.post(
|
|
"/api/v1/auth/setup",
|
|
json={
|
|
"auth_enabled": True,
|
|
"admin_username": "ownershipadmin",
|
|
"admin_password": "AdminPass1!",
|
|
},
|
|
)
|
|
|
|
# Login as admin
|
|
admin_login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "ownershipadmin", "password": "AdminPass1!"},
|
|
)
|
|
admin_token = admin_login.json()["access_token"]
|
|
admin_user = admin_login.json()["user"]
|
|
|
|
# Get group IDs
|
|
groups_response = await async_client.get(
|
|
"/api/v1/groups/",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
groups = groups_response.json()
|
|
operators_group = next(g for g in groups if g["name"] == "Operators")
|
|
viewers_group = next(g for g in groups if g["name"] == "Viewers")
|
|
|
|
# Create operator user (has *_own permissions)
|
|
operator_response = await async_client.post(
|
|
"/api/v1/users/",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
json={
|
|
"username": "operator1",
|
|
"password": "Operatorpass1!",
|
|
"group_ids": [operators_group["id"]],
|
|
},
|
|
)
|
|
operator_user = operator_response.json()
|
|
|
|
# Login as operator
|
|
operator_login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "operator1", "password": "Operatorpass1!"},
|
|
)
|
|
operator_token = operator_login.json()["access_token"]
|
|
|
|
# Create second operator (for cross-user tests)
|
|
operator2_response = await async_client.post(
|
|
"/api/v1/users/",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
json={
|
|
"username": "operator2",
|
|
"password": "Operatorpass1!",
|
|
"group_ids": [operators_group["id"]],
|
|
},
|
|
)
|
|
operator2_user = operator2_response.json()
|
|
|
|
operator2_login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "operator2", "password": "Operatorpass1!"},
|
|
)
|
|
operator2_token = operator2_login.json()["access_token"]
|
|
|
|
# Create viewer user (has no update/delete permissions)
|
|
await async_client.post(
|
|
"/api/v1/users/",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
json={
|
|
"username": "viewer1",
|
|
"password": "Viewerpass1!",
|
|
"group_ids": [viewers_group["id"]],
|
|
},
|
|
)
|
|
|
|
viewer_login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "viewer1", "password": "Viewerpass1!"},
|
|
)
|
|
viewer_token = viewer_login.json()["access_token"]
|
|
|
|
return {
|
|
"admin_token": admin_token,
|
|
"admin_user": admin_user,
|
|
"operator_token": operator_token,
|
|
"operator_user": operator_user,
|
|
"operator2_token": operator2_token,
|
|
"operator2_user": operator2_user,
|
|
"viewer_token": viewer_token,
|
|
}
|
|
|
|
|
|
class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
|
|
"""Tests for archive ownership-based permissions."""
|
|
|
|
# ========================================================================
|
|
# DELETE permissions
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_delete_any_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Admin with *_all permissions can delete any archive."""
|
|
printer = await printer_factory()
|
|
# Create archive owned by operator
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Operator Archive",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
|
|
# Admin deletes it
|
|
response = await async_client.delete(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_delete_own_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Operator with *_own permissions can delete their own archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="My Archive",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_others_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Operator with *_own permissions cannot delete another user's archive."""
|
|
printer = await printer_factory()
|
|
# Archive created by operator2
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Other's Archive",
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
)
|
|
|
|
# operator1 tries to delete it
|
|
response = await async_client.delete(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert "your own" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_ownerless_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Operator with *_own permissions cannot delete ownerless archive."""
|
|
printer = await printer_factory()
|
|
# Archive with no owner (legacy data)
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Ownerless Archive",
|
|
created_by_id=None,
|
|
)
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_viewer_cannot_delete_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Viewer with no delete permissions cannot delete any archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, print_name="Any Archive")
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
# ========================================================================
|
|
# UPDATE permissions
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_update_any_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Admin can update any archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Original Name",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.patch(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
json={"print_name": "Admin Updated"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["print_name"] == "Admin Updated"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_update_own_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Operator can update their own archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Original Name",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.patch(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"print_name": "Operator Updated"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["print_name"] == "Operator Updated"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_update_others_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Operator cannot update another user's archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Other's Archive",
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.patch(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"print_name": "Attempted Update"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
# ========================================================================
|
|
# Legacy reprint endpoint
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_reprint_endpoint_is_gone_for_all_callers(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Direct archive reprint no longer exists; callers must use the queue."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 410
|
|
|
|
# ========================================================================
|
|
# Queue route — archives:reprint_* gate (#1625)
|
|
# ========================================================================
|
|
# The unified /queue/ route replaced the legacy /reprint endpoint; the
|
|
# reprint permission gate must move with it. Without these checks a
|
|
# caller with QUEUE_CREATE + ARCHIVES_READ_OWN could reprint their own
|
|
# archives even if explicitly denied ARCHIVES_REPRINT_OWN.
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_queue_route_operator_can_reprint_own_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Operator with REPRINT_OWN can queue their own archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/queue/",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"printer_id": printer.id, "archive_id": archive.id},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_queue_route_user_without_reprint_gets_403(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""User with QUEUE_CREATE + ARCHIVES_READ_OWN but no reprint perm → 403.
|
|
|
|
Custom group mirrors a real operator policy where someone is allowed
|
|
to enqueue freshly-uploaded library files but explicitly NOT allowed
|
|
to re-run completed archives.
|
|
"""
|
|
# Create custom group with queue:create + archives:read_own but no reprint perm.
|
|
admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
|
|
group_resp = await async_client.post(
|
|
"/api/v1/groups/",
|
|
headers=admin_headers,
|
|
json={
|
|
"name": "QueueOnlyNoReprint",
|
|
"description": "Test group: can queue library files but not reprint",
|
|
"permissions": [
|
|
"queue:create",
|
|
"queue:read_own",
|
|
"archives:read_own",
|
|
"library:read_own",
|
|
"library:upload",
|
|
"printers:read",
|
|
],
|
|
},
|
|
)
|
|
assert group_resp.status_code in (200, 201)
|
|
group_id = group_resp.json()["id"]
|
|
|
|
await async_client.post(
|
|
"/api/v1/users/",
|
|
headers=admin_headers,
|
|
json={
|
|
"username": "noreprint_user",
|
|
"password": "NoreprintPass1!",
|
|
"group_ids": [group_id],
|
|
},
|
|
)
|
|
login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "noreprint_user", "password": "NoreprintPass1!"},
|
|
)
|
|
token = login.json()["access_token"]
|
|
user_id = login.json()["user"]["id"]
|
|
|
|
# Archive owned by the no-reprint user.
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, created_by_id=user_id)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/queue/",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"printer_id": printer.id, "archive_id": archive.id},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert "reprint" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_batch_dispatch_allowed_for_own_archive_with_reprint_own(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""The dispatch gate must not block the ordinary self-service case (#342)."""
|
|
headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
order = await async_client.post(
|
|
"/api/v1/queue/batches",
|
|
headers=headers,
|
|
json={
|
|
"name": "Own order",
|
|
"archive_id": archive.id,
|
|
"plates": [{"plate_id": 1, "quantity_target": 2}],
|
|
},
|
|
)
|
|
assert order.status_code == 200
|
|
batch_id = order.json()["id"]
|
|
assert (
|
|
await async_client.post(
|
|
"/api/v1/queue/",
|
|
headers=headers,
|
|
json={
|
|
"printer_id": printer.id,
|
|
"archive_id": archive.id,
|
|
"batch_id": batch_id,
|
|
"plate_id": 1,
|
|
},
|
|
)
|
|
).status_code == 200
|
|
|
|
response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", headers=headers, json={})
|
|
assert response.status_code == 200
|
|
assert response.json()["remaining_count"] == 0
|
|
assert response.json()["pending_count"] == 2
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_batch_dispatch_honours_the_reprint_gate(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Dispatching a batch order must not be a weaker door than POST /queue/ (#342).
|
|
|
|
Dispatch clones existing queue items, so without the same source-file
|
|
gate a caller holding queue:create and queue:update_all — but
|
|
explicitly denied archives:reprint_* — could start prints of an
|
|
archive that POST /queue/ would have refused them.
|
|
"""
|
|
admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
|
|
group_resp = await async_client.post(
|
|
"/api/v1/groups/",
|
|
headers=admin_headers,
|
|
json={
|
|
"name": "BatchDispatchNoReprint",
|
|
"description": "Test group: can manage the queue but not reprint archives",
|
|
"permissions": [
|
|
"queue:create",
|
|
"queue:read_all",
|
|
"queue:update_all",
|
|
"archives:read_all",
|
|
"printers:read",
|
|
],
|
|
},
|
|
)
|
|
assert group_resp.status_code in (200, 201)
|
|
await async_client.post(
|
|
"/api/v1/users/",
|
|
headers=admin_headers,
|
|
json={
|
|
"username": "batch_noreprint_user",
|
|
"password": "BatchNoreprint1!",
|
|
"group_ids": [group_resp.json()["id"]],
|
|
},
|
|
)
|
|
login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "batch_noreprint_user", "password": "BatchNoreprint1!"},
|
|
)
|
|
token = login.json()["access_token"]
|
|
|
|
# Admin builds an order with one dispatched run and two still owed.
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, created_by_id=auth_setup["admin_user"]["id"])
|
|
order = await async_client.post(
|
|
"/api/v1/queue/batches",
|
|
headers=admin_headers,
|
|
json={
|
|
"name": "Gated order",
|
|
"archive_id": archive.id,
|
|
"plates": [{"plate_id": 1, "quantity_target": 3}],
|
|
},
|
|
)
|
|
assert order.status_code == 200
|
|
batch_id = order.json()["id"]
|
|
seeded = await async_client.post(
|
|
"/api/v1/queue/",
|
|
headers=admin_headers,
|
|
json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": batch_id, "plate_id": 1},
|
|
)
|
|
assert seeded.status_code == 200
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/batches/{batch_id}/dispatch",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert "reprint" in response.json()["detail"].lower()
|
|
|
|
# And nothing was queued behind the refusal.
|
|
listing = await async_client.get(f"/api/v1/queue/batches/{batch_id}", headers=admin_headers)
|
|
assert listing.json()["pending_count"] == 1
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_queue_route_ownerless_archive_requires_reprint_all(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Ownerless archive (created_by_id=null) requires REPRINT_ALL.
|
|
|
|
Pre-IDOR-fix legacy data has no creator; an operator with
|
|
REPRINT_OWN can't fall back to "I own this" — fail-closed.
|
|
The existing IDOR check returns 404 first (operator lacks
|
|
READ_ALL and doesn't own the row), so this is also a regression
|
|
guard against accidentally surfacing 403-instead-of-404 if the
|
|
IDOR check is ever loosened.
|
|
"""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, created_by_id=None)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/queue/",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"printer_id": printer.id, "archive_id": archive.id},
|
|
)
|
|
|
|
# IDOR returns 404 before the new gate fires for this operator.
|
|
assert response.status_code == 404
|
|
|
|
|
|
class TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup):
|
|
"""Tests for print queue ownership-based permissions."""
|
|
|
|
@pytest.fixture
|
|
async def queue_item_factory(self, db_session, printer_factory, archive_factory):
|
|
"""Factory to create test queue items."""
|
|
|
|
async def _create_item(**kwargs):
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
printer = await printer_factory()
|
|
# Create an archive to link to the queue item
|
|
archive = await archive_factory(printer.id)
|
|
|
|
defaults = {
|
|
"printer_id": printer.id,
|
|
"archive_id": archive.id,
|
|
"status": "pending",
|
|
"position": 0,
|
|
}
|
|
defaults.update(kwargs)
|
|
|
|
item = PrintQueueItem(**defaults)
|
|
db_session.add(item)
|
|
await db_session.commit()
|
|
await db_session.refresh(item)
|
|
return item
|
|
|
|
return _create_item
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_delete_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
|
|
"""Admin can delete any queue item."""
|
|
item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/queue/{item.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_delete_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
|
|
"""Operator can delete their own queue item."""
|
|
item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/queue/{item.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_others_queue_item(
|
|
self, async_client: AsyncClient, auth_setup, queue_item_factory
|
|
):
|
|
"""Operator cannot delete another user's queue item."""
|
|
item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/queue/{item.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_update_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
|
|
"""Operator can update their own queue item."""
|
|
item = await queue_item_factory(created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.patch(
|
|
f"/api/v1/queue/{item.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"position": 10},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_update_others_queue_item(
|
|
self, async_client: AsyncClient, auth_setup, queue_item_factory
|
|
):
|
|
"""Operator cannot update another user's queue item."""
|
|
item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
|
|
response = await async_client.patch(
|
|
f"/api/v1/queue/{item.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"position": 10},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_cancel_others_queue_item(
|
|
self, async_client: AsyncClient, auth_setup, queue_item_factory
|
|
):
|
|
"""Operator cannot cancel another user's queue item."""
|
|
item = await queue_item_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/cancel",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
# ========================================================================
|
|
# Start / Stop ownership gates (#1625-followup)
|
|
# ========================================================================
|
|
# Pre-fix /stop required QUEUE_UPDATE_ALL (admin-only) — operators saw the
|
|
# Stop button in the queue UI but got 403 on click. /start required
|
|
# QUEUE_UPDATE_OWN with no ownership check — operators could start anyone's
|
|
# queue items via direct API. Both now use require_ownership_permission.
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_start_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
|
|
"""Operator can start their own staged queue item."""
|
|
item = await queue_item_factory(
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
manual_start=True,
|
|
)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_start_others_queue_item(
|
|
self, async_client: AsyncClient, auth_setup, queue_item_factory
|
|
):
|
|
"""Operator cannot start another user's queue item."""
|
|
item = await queue_item_factory(
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
manual_start=True,
|
|
)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_start_unowned_queue_item(
|
|
self, async_client: AsyncClient, auth_setup, queue_item_factory, db_session
|
|
):
|
|
"""Operator can start a NULL-owner queue item (VP-uploaded, #1670)
|
|
and claims ownership in the process.
|
|
|
|
Stop and Cancel reject unowned items for _OWN holders (destructive,
|
|
no "I own it" claim available), but Start is the entry point for the
|
|
VP-import flow where attribution happens at click-time.
|
|
"""
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
item = await queue_item_factory(created_by_id=None, manual_start=True)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
# Ownership claimed: operator is now the item's owner.
|
|
await db_session.refresh(item)
|
|
refetch = await db_session.get(PrintQueueItem, item.id)
|
|
assert refetch.created_by_id == auth_setup["operator_user"]["id"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_stop_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
|
|
"""Operator can stop their own currently-printing queue item."""
|
|
item = await queue_item_factory(
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
status="printing",
|
|
)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/stop",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_stop_others_queue_item(
|
|
self, async_client: AsyncClient, auth_setup, queue_item_factory
|
|
):
|
|
"""Operator cannot stop another user's printing queue item."""
|
|
item = await queue_item_factory(
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
status="printing",
|
|
)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/stop",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_stop_unowned_queue_item(
|
|
self, async_client: AsyncClient, auth_setup, queue_item_factory
|
|
):
|
|
"""Operator cannot stop a NULL-owner printing queue item — stop mirrors
|
|
cancel (destructive, no claim semantics). Admins with _ALL can still stop it.
|
|
"""
|
|
item = await queue_item_factory(created_by_id=None, status="printing")
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/stop",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_stop_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
|
|
"""Admin with _ALL can stop any printing queue item including unowned."""
|
|
item = await queue_item_factory(created_by_id=None, status="printing")
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{item.id}/stop",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_update_skips_non_owned_items(self, async_client: AsyncClient, auth_setup, queue_item_factory):
|
|
"""Bulk update only updates items the user owns."""
|
|
# Create items owned by different users
|
|
own_item = await queue_item_factory(
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
other_item = await queue_item_factory(
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.patch(
|
|
"/api/v1/queue/bulk",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={
|
|
"item_ids": [own_item.id, other_item.id],
|
|
"manual_start": True,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
# Should only update the owned item
|
|
assert result["updated_count"] == 1
|
|
assert result["skipped_count"] == 1
|
|
|
|
|
|
class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
|
|
"""Tests for library file ownership-based permissions."""
|
|
|
|
@pytest.fixture
|
|
async def library_file_factory(self, db_session):
|
|
"""Factory to create test library files."""
|
|
_counter = [0]
|
|
|
|
async def _create_file(**kwargs):
|
|
from backend.app.models.library import LibraryFile
|
|
|
|
_counter[0] += 1
|
|
defaults = {
|
|
"filename": f"test_{_counter[0]}.3mf",
|
|
"file_path": f"library/test_{_counter[0]}.3mf",
|
|
"file_type": "3mf",
|
|
"file_size": 1024,
|
|
}
|
|
defaults.update(kwargs)
|
|
|
|
file = LibraryFile(**defaults)
|
|
db_session.add(file)
|
|
await db_session.commit()
|
|
await db_session.refresh(file)
|
|
return file
|
|
|
|
return _create_file
|
|
|
|
@pytest.fixture
|
|
async def library_folder_factory(self, db_session):
|
|
"""Factory to create test library folders."""
|
|
_counter = [0]
|
|
|
|
async def _create_folder(**kwargs):
|
|
from backend.app.models.library import LibraryFolder
|
|
|
|
_counter[0] += 1
|
|
defaults = {
|
|
"name": f"TestFolder_{_counter[0]}",
|
|
}
|
|
defaults.update(kwargs)
|
|
|
|
folder = LibraryFolder(**defaults)
|
|
db_session.add(folder)
|
|
await db_session.commit()
|
|
await db_session.refresh(folder)
|
|
return folder
|
|
|
|
return _create_folder
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_delete_any_library_file(self, async_client: AsyncClient, auth_setup, library_file_factory):
|
|
"""Admin can delete any library file."""
|
|
file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/files/{file.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_delete_own_library_file(
|
|
self, async_client: AsyncClient, auth_setup, library_file_factory
|
|
):
|
|
"""Operator can delete their own library file."""
|
|
file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/files/{file.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_others_library_file(
|
|
self, async_client: AsyncClient, auth_setup, library_file_factory
|
|
):
|
|
"""Operator cannot delete another user's library file."""
|
|
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/files/{file.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_update_own_library_file(
|
|
self, async_client: AsyncClient, auth_setup, library_file_factory
|
|
):
|
|
"""Operator can update their own library file."""
|
|
file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.put(
|
|
f"/api/v1/library/files/{file.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"filename": "renamed.3mf"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_update_others_library_file(
|
|
self, async_client: AsyncClient, auth_setup, library_file_factory
|
|
):
|
|
"""Operator cannot update another user's library file."""
|
|
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
|
|
response = await async_client.put(
|
|
f"/api/v1/library/files/{file.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"filename": "renamed.3mf"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
# ========================================================================
|
|
# Folder deletion (#1781): folders have no ownership tracking, so users
|
|
# with only library:delete_own may delete empty, non-external, non-linked
|
|
# folders. Everything else still requires library:delete_all.
|
|
# ========================================================================
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_delete_empty_folder(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
|
):
|
|
"""A user with library:delete_own can delete an empty folder (#1781)."""
|
|
folder = await library_folder_factory(name="EmptyFolder")
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{folder.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_viewer_cannot_delete_empty_folder(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
|
):
|
|
"""No delete permission at all still means no folder deletion."""
|
|
folder = await library_folder_factory(name="EmptyFolder")
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{folder.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_folder_with_files(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
|
):
|
|
"""Non-empty folders still require library:delete_all."""
|
|
folder = await library_folder_factory(name="FullFolder")
|
|
await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{folder.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_folder_with_trashed_file(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
|
):
|
|
"""Trashed files count as content: cascade would hard-drop them and
|
|
silently break trash restore for their owner."""
|
|
from datetime import datetime, timezone
|
|
|
|
folder = await library_folder_factory(name="TrashedContentFolder")
|
|
await library_file_factory(
|
|
folder_id=folder.id,
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
deleted_at=datetime.now(timezone.utc),
|
|
)
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{folder.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_folder_with_subfolder(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
|
):
|
|
"""A folder containing subfolders (even empty ones) is not empty."""
|
|
parent = await library_folder_factory(name="ParentFolder")
|
|
await library_folder_factory(name="ChildFolder", parent_id=parent.id)
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{parent.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_external_folder(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
|
):
|
|
"""Deleting an external folder unmounts an operator-configured mount
|
|
for everyone — stays behind library:delete_all even when empty."""
|
|
folder = await library_folder_factory(name="ExternalFolder", is_external=True, external_path="/mnt/models")
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{folder.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_delete_linked_folder(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory, db_session
|
|
):
|
|
"""Project/archive links are created via update_all, so unlinking by
|
|
deletion stays admin-only even for empty folders."""
|
|
from backend.app.models.project import Project
|
|
|
|
project = Project(name="LinkTestProject")
|
|
db_session.add(project)
|
|
await db_session.commit()
|
|
await db_session.refresh(project)
|
|
|
|
folder = await library_folder_factory(name="LinkedFolder", project_id=project.id)
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{folder.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_delete_folder_with_contents(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
|
):
|
|
"""library:delete_all keeps full cascade deletion."""
|
|
folder = await library_folder_factory(name="AdminFolder")
|
|
await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
|
|
|
|
response = await async_client.delete(
|
|
f"/api/v1/library/folders/{folder.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_delete_operator_folders_empty_only(
|
|
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
|
):
|
|
"""Bulk delete applies the same rule: empty folders go, non-empty are skipped."""
|
|
empty_folder = await library_folder_factory(name="BulkEmpty")
|
|
full_folder = await library_folder_factory(name="BulkFull")
|
|
await library_file_factory(folder_id=full_folder.id, created_by_id=auth_setup["operator2_user"]["id"])
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/library/bulk-delete",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"file_ids": [], "folder_ids": [empty_folder.id, full_folder.id]},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["deleted_folders"] == 1
|
|
assert result["deleted_files"] == 0
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):
|
|
"""Bulk delete only deletes files the user owns."""
|
|
own_file = await library_file_factory(
|
|
filename="own.3mf",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
other_file = await library_file_factory(
|
|
filename="other.3mf",
|
|
created_by_id=auth_setup["operator2_user"]["id"],
|
|
)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/library/bulk-delete",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"file_ids": [own_file.id, other_file.id], "folder_ids": []},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
# Should only delete the owned file; other_file is skipped (but skipped count not in response)
|
|
assert result["deleted_files"] == 1
|
|
|
|
|
|
class TestAuthDisabledPermissions:
|
|
"""Tests that verify all operations are allowed when auth is disabled."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_archive_without_auth(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""When auth is disabled, anyone can delete archives."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.delete(f"/api/v1/archives/{archive.id}")
|
|
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_update_archive_without_auth(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
|
):
|
|
"""When auth is disabled, anyone can update archives."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id)
|
|
|
|
response = await async_client.patch(
|
|
f"/api/v1/archives/{archive.id}",
|
|
json={"print_name": "Updated Name"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
|
|
"""Tests for user items count endpoint and deletion with items."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_get_user_items_count(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify items count endpoint returns correct counts."""
|
|
printer = await printer_factory()
|
|
user_id = auth_setup["operator_user"]["id"]
|
|
|
|
# Create some items for the operator
|
|
await archive_factory(printer.id, created_by_id=user_id)
|
|
await archive_factory(printer.id, created_by_id=user_id)
|
|
|
|
response = await async_client.get(
|
|
f"/api/v1/users/{user_id}/items-count",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
counts = response.json()
|
|
assert counts["archives"] >= 2
|
|
assert "queue_items" in counts
|
|
assert "library_files" in counts
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_user_keeps_items(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify deleting user without delete_items keeps items (ownerless)."""
|
|
printer = await printer_factory()
|
|
user_id = auth_setup["operator2_user"]["id"]
|
|
|
|
# Create archive for operator2
|
|
archive = await archive_factory(printer.id, created_by_id=user_id)
|
|
archive_id = archive.id
|
|
|
|
# Delete user without deleting items
|
|
response = await async_client.delete(
|
|
f"/api/v1/users/{user_id}?delete_items=false",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
|
|
# Verify archive still exists but is now ownerless
|
|
archive_response = await async_client.get(
|
|
f"/api/v1/archives/{archive_id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
assert archive_response.status_code == 200
|
|
assert archive_response.json()["created_by_id"] is None
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_delete_user_with_items(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Verify deleting user with delete_items=true removes their items."""
|
|
printer = await printer_factory()
|
|
|
|
# Create a new user with items
|
|
create_response = await async_client.post(
|
|
"/api/v1/users/",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
json={
|
|
"username": "deletewithitems",
|
|
"password": "Password123!",
|
|
},
|
|
)
|
|
user_id = create_response.json()["id"]
|
|
|
|
# Create archive for this user
|
|
archive = await archive_factory(printer.id, created_by_id=user_id)
|
|
archive_id = archive.id
|
|
|
|
# Delete user WITH deleting items
|
|
response = await async_client.delete(
|
|
f"/api/v1/users/{user_id}?delete_items=true",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
|
|
# Verify archive was deleted
|
|
archive_response = await async_client.get(
|
|
f"/api/v1/archives/{archive_id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
assert archive_response.status_code == 404
|
|
|
|
|
|
class TestReadIDORClosure(TestOwnershipPermissionsSetup):
|
|
"""Regression tests pinning maziggy/bambuddy-security #2 — IDOR on
|
|
archives / library / queue read paths.
|
|
|
|
Before the fix, ARCHIVES_READ / LIBRARY_READ / QUEUE_READ were flat
|
|
"see everything" permissions even though the write side was split into
|
|
OWN/ALL. An operator with only ARCHIVES_READ could read, download, and
|
|
queue any user's archive via direct id reference. These tests pin the
|
|
bambuddy_archive_idor.py and bambuddy_archive_viewer_idor.py PoC paths
|
|
so the IDOR can't regress silently.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_get_others_archive_returns_404_not_200(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""PoC #2 read path. operator1 GET /archives/{id} where id is admin's
|
|
archive must NOT leak the row. 404 (not 403) so the operator can't
|
|
enumerate which ids exist — same shape as a nonexistent id."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Admin Archive",
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
response = await async_client.get(
|
|
f"/api/v1/archives/{archive.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_download_others_archive_returns_404(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Viewer-IDOR PoC path: GET /archives/{id}/download on admin's archive.
|
|
Before the fix this streamed the 3MF body straight to a viewer-tier
|
|
token."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Admin Archive 2",
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
response = await async_client.get(
|
|
f"/api/v1/archives/{archive.id}/download",
|
|
headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_list_archives_excludes_others(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""GET /archives/ must filter to own archives only for OWN-level callers."""
|
|
printer = await printer_factory()
|
|
own = await archive_factory(
|
|
printer.id, print_name="Operator's Own", created_by_id=auth_setup["operator_user"]["id"]
|
|
)
|
|
others = await archive_factory(printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"])
|
|
response = await async_client.get(
|
|
"/api/v1/archives/",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert response.status_code == 200
|
|
returned_ids = {a["id"] for a in response.json()}
|
|
assert own.id in returned_ids
|
|
assert others.id not in returned_ids
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_list_archives_includes_all(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""ARCHIVES_READ_ALL → admin sees own + every user's archives."""
|
|
printer = await printer_factory()
|
|
admin_archive = await archive_factory(
|
|
printer.id, print_name="Admin's", created_by_id=auth_setup["admin_user"]["id"]
|
|
)
|
|
operator_archive = await archive_factory(
|
|
printer.id, print_name="Operator's", created_by_id=auth_setup["operator_user"]["id"]
|
|
)
|
|
response = await async_client.get(
|
|
"/api/v1/archives/",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
assert response.status_code == 200
|
|
returned_ids = {a["id"] for a in response.json()}
|
|
assert admin_archive.id in returned_ids
|
|
assert operator_archive.id in returned_ids
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_queue_others_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""PoC #2 queue path. POST /queue/ with admin's archive_id as
|
|
operator1 must return 404, not create a queue item. Before the fix
|
|
this returned 201 and queued the admin archive (Landon's CONFIRMED
|
|
line in the PoC)."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Admin Archive (queue-target)",
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
response = await async_client.post(
|
|
"/api/v1/queue/",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_queue_others_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Belt-and-suspenders for the ALL path: admin (ARCHIVES_READ_ALL) can
|
|
queue a user's archive on their behalf — common workshop pattern."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Operator's archive (queue by admin)",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
response = await async_client.post(
|
|
"/api/v1/queue/",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
json={"archive_id": archive.id, "printer_id": printer.id, "quantity": 1},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_get_others_library_file_returns_404(
|
|
self, async_client: AsyncClient, auth_setup, db_session
|
|
):
|
|
"""Library IDOR closure (same shape as archives — closed in the same PR
|
|
per maziggy/bambuddy-security #2)."""
|
|
from backend.app.models.library import LibraryFile
|
|
|
|
admin_file = LibraryFile(
|
|
filename="admin_secret.3mf",
|
|
file_path="library/admin_secret.3mf",
|
|
file_type="3mf",
|
|
file_size=2048,
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
db_session.add(admin_file)
|
|
await db_session.commit()
|
|
await db_session.refresh(admin_file)
|
|
|
|
response = await async_client.get(
|
|
f"/api/v1/library/files/{admin_file.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_list_library_files_excludes_others(self, async_client: AsyncClient, auth_setup, db_session):
|
|
from backend.app.models.library import LibraryFile
|
|
|
|
own = LibraryFile(
|
|
filename="my_file.3mf",
|
|
file_path="library/my_file.3mf",
|
|
file_type="3mf",
|
|
file_size=1024,
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
others = LibraryFile(
|
|
filename="admin_file.3mf",
|
|
file_path="library/admin_file.3mf",
|
|
file_type="3mf",
|
|
file_size=1024,
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
db_session.add_all([own, others])
|
|
await db_session.commit()
|
|
await db_session.refresh(own)
|
|
await db_session.refresh(others)
|
|
|
|
response = await async_client.get(
|
|
"/api/v1/library/files",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert response.status_code == 200
|
|
returned_ids = {f["id"] for f in response.json()}
|
|
assert own.id in returned_ids
|
|
assert others.id not in returned_ids
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_queue_list_excludes_others_items(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""GET /queue/ must filter to own queue items only for OWN callers —
|
|
same shape as the archive list."""
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["operator_user"]["id"])
|
|
own_item = PrintQueueItem(
|
|
archive_id=archive.id,
|
|
printer_id=printer.id,
|
|
status="pending",
|
|
position=1,
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
admin_item = PrintQueueItem(
|
|
archive_id=archive.id,
|
|
printer_id=printer.id,
|
|
status="pending",
|
|
position=2,
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
db_session.add_all([own_item, admin_item])
|
|
await db_session.commit()
|
|
await db_session.refresh(own_item)
|
|
await db_session.refresh(admin_item)
|
|
|
|
response = await async_client.get(
|
|
"/api/v1/queue/",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert response.status_code == 200
|
|
returned_ids = {q["id"] for q in response.json()}
|
|
assert own_item.id in returned_ids
|
|
assert admin_item.id not in returned_ids
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_get_others_queue_item_returns_404(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Direct-id queue item access — same enumeration risk as archive get."""
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, print_name="A", created_by_id=auth_setup["admin_user"]["id"])
|
|
admin_item = PrintQueueItem(
|
|
archive_id=archive.id,
|
|
printer_id=printer.id,
|
|
status="pending",
|
|
position=1,
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
db_session.add(admin_item)
|
|
await db_session.commit()
|
|
await db_session.refresh(admin_item)
|
|
|
|
response = await async_client.get(
|
|
f"/api/v1/queue/{admin_item.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_auth_disabled_preserves_single_tenant_read_all(
|
|
self, async_client: AsyncClient, archive_factory, printer_factory
|
|
):
|
|
"""With auth disabled, ARCHIVES_READ resolves to read-all (can_modify_all=True
|
|
in require_ownership_permission's auth-disabled branch). Existing
|
|
single-user installs see no behavior change."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, print_name="Anonymous", created_by_id=None)
|
|
# No Authorization header — auth-disabled mode.
|
|
response = await async_client.get(f"/api/v1/archives/{archive.id}")
|
|
# Either 200 (auth disabled in this test session) or 401 (auth enabled
|
|
# from a prior test) — both are acceptable; the IDOR closure does not
|
|
# change auth-enable/disable behavior. Pin not-404 to avoid masking a
|
|
# regression where auth-disabled callers would lose access.
|
|
assert response.status_code in (200, 401)
|
|
|
|
|
|
# Every archive WRITE sub-resource route: (id, http method, path suffix, request kwargs).
|
|
# The ownership gate (_ensure_archive_visible) fires immediately after the fetch,
|
|
# before any resource-specific logic, so a not-owned / ownerless row 404s regardless
|
|
# of whether the timelapse / photo / source / f3d actually exists. Upload routes still
|
|
# need a body so FastAPI reaches the handler instead of 422-ing on the missing File(...).
|
|
_WRITE_SUBRESOURCE_ROUTES = [
|
|
("favorite", "post", "/favorite", {}),
|
|
("timelapse_delete", "delete", "/timelapse", {}),
|
|
("photo_upload", "post", "/photos", {"files": {"file": ("x.jpg", b"\x89PNG\r\n\x1a\n", "image/jpeg")}}),
|
|
("photo_delete", "delete", "/photos/nonexistent.jpg", {}),
|
|
("project_page", "patch", "/project-page", {"json": {"title": "hijacked"}}),
|
|
("source_upload", "post", "/source", {"files": {"file": ("x.3mf", b"PK\x03\x04", "application/octet-stream")}}),
|
|
("source_delete", "delete", "/source", {}),
|
|
("f3d_upload", "post", "/f3d", {"files": {"file": ("x.f3d", b"f3d-bytes", "application/octet-stream")}}),
|
|
("f3d_delete", "delete", "/f3d", {}),
|
|
]
|
|
|
|
|
|
class TestWriteSubResourceIDORClosure(TestOwnershipPermissionsSetup):
|
|
"""Regression tests for the archive write SUB-RESOURCE IDOR.
|
|
|
|
The read sub-resource routes were closed under maziggy/bambuddy-security #2
|
|
via ``_ensure_archive_visible``, but the *write* sub-resource routes
|
|
(favorite, timelapse, photos, project-page, source, f3d) were left gating
|
|
on the bare ``RequirePermissionIfAuthEnabled(ARCHIVES_*_OWN)`` scope and
|
|
fetched the row by id only — never comparing ``created_by_id`` to the
|
|
caller. An operator holding only ``ARCHIVES_*_OWN`` (or an API key with
|
|
``can_manage_archives``) could delete/overwrite files on ANY user's
|
|
archive, most severely rewriting the project-page metadata inside another
|
|
user's ``.3mf`` on disk. Each route is now gated by
|
|
``require_ownership_permission`` + ``_ensure_archive_visible`` → 404 (not
|
|
403, to stay non-enumerable and match the read side) on a not-owned or
|
|
ownerless row.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,method,suffix,kwargs",
|
|
_WRITE_SUBRESOURCE_ROUTES,
|
|
ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
|
|
)
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_write_others_archive_subresource(
|
|
self,
|
|
async_client: AsyncClient,
|
|
auth_setup,
|
|
archive_factory,
|
|
printer_factory,
|
|
db_session,
|
|
name,
|
|
method,
|
|
suffix,
|
|
kwargs,
|
|
):
|
|
"""SECURITY.md rule 4: right credentials, wrong ownership → 404.
|
|
|
|
operator1 (ARCHIVES_*_OWN) targeting a route on admin's archive.
|
|
"""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Admin's Archive",
|
|
created_by_id=auth_setup["admin_user"]["id"],
|
|
)
|
|
response = await getattr(async_client, method)(
|
|
f"/api/v1/archives/{archive.id}{suffix}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
**kwargs,
|
|
)
|
|
assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,method,suffix,kwargs",
|
|
_WRITE_SUBRESOURCE_ROUTES,
|
|
ids=[r[0] for r in _WRITE_SUBRESOURCE_ROUTES],
|
|
)
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_write_ownerless_archive_subresource(
|
|
self,
|
|
async_client: AsyncClient,
|
|
auth_setup,
|
|
archive_factory,
|
|
printer_factory,
|
|
db_session,
|
|
name,
|
|
method,
|
|
suffix,
|
|
kwargs,
|
|
):
|
|
"""Ownerless rows (created_by_id = null, legacy data) require *_ALL — an
|
|
operator with only *_OWN has no 'I own this' claim, so fail closed → 404."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Ownerless Archive",
|
|
created_by_id=None,
|
|
)
|
|
response = await getattr(async_client, method)(
|
|
f"/api/v1/archives/{archive.id}{suffix}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
**kwargs,
|
|
)
|
|
assert response.status_code == 404, f"{name}: expected 404, got {response.status_code}"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_favorite_own_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Positive control: the owner still gets through the new gate. Favorite
|
|
is the one write sub-resource that needs no pre-existing file, so it
|
|
cleanly proves the *_OWN happy path returns 200 (not a false 404)."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Operator's Own",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
response = await async_client.post(
|
|
f"/api/v1/archives/{archive.id}/favorite",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["is_favorite"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_favorite_any_archive(
|
|
self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
|
|
):
|
|
"""Positive control for the *_ALL path: admin can act on a user's archive."""
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(
|
|
printer.id,
|
|
print_name="Operator's Own",
|
|
created_by_id=auth_setup["operator_user"]["id"],
|
|
)
|
|
response = await async_client.post(
|
|
f"/api/v1/archives/{archive.id}/favorite",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
|
|
class TestSliceOwnershipPermissions(TestOwnershipPermissionsSetup):
|
|
"""IDOR regression: slicing and slice-job polling must honour per-row ownership.
|
|
|
|
Before the fix, ``POST /library/files/{id}/slice`` and
|
|
``POST /archives/{id}/slice`` gated only on ``LIBRARY_UPLOAD``, so a
|
|
READ_OWN operator could slice another user's model by raw id even though a
|
|
direct GET on that id returned 404 — the sliced output was then attributed
|
|
to and downloadable by the requester. ``GET /slice-jobs/{id}`` had no owner
|
|
scoping at all. ``POST /slicer-pipelines/{id}/run`` (and check-eligibility)
|
|
resolved the source by raw id with the same gap.
|
|
|
|
The slice route enforces the gate before touching the source bytes, so the
|
|
owner/READ_ALL "control" cases reach the later on-disk check (a distinct 404
|
|
detail) rather than a real slice — enough to prove the gate lets them past.
|
|
"""
|
|
|
|
# Any preset triplet: the ownership 404 fires before preset resolution.
|
|
_SLICE_BODY = {"printer_preset_id": 1, "process_preset_id": 2, "filament_preset_id": 3}
|
|
|
|
@pytest.fixture
|
|
async def library_file_factory(self, db_session):
|
|
_counter = [0]
|
|
|
|
async def _create_file(**kwargs):
|
|
from backend.app.models.library import LibraryFile
|
|
|
|
_counter[0] += 1
|
|
defaults = {
|
|
"filename": f"slice_src_{_counter[0]}.3mf",
|
|
"file_path": f"library/slice_src_{_counter[0]}.3mf",
|
|
"file_type": "3mf",
|
|
"file_size": 1024,
|
|
}
|
|
defaults.update(kwargs)
|
|
row = LibraryFile(**defaults)
|
|
db_session.add(row)
|
|
await db_session.commit()
|
|
await db_session.refresh(row)
|
|
return row
|
|
|
|
return _create_file
|
|
|
|
# --- library file slice ------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_slice_others_library_file(self, async_client, auth_setup, library_file_factory):
|
|
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
resp = await async_client.post(
|
|
f"/api/v1/library/files/{file.id}/slice",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json=self._SLICE_BODY,
|
|
)
|
|
assert resp.status_code == 404
|
|
# 404 (not 403) so a probing operator can't tell the id exists.
|
|
assert resp.json()["detail"] == "File not found"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_slice_own_library_file(self, async_client, auth_setup, library_file_factory):
|
|
file = await library_file_factory(created_by_id=auth_setup["operator_user"]["id"])
|
|
resp = await async_client.post(
|
|
f"/api/v1/library/files/{file.id}/slice",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json=self._SLICE_BODY,
|
|
)
|
|
# Past the ownership gate — only the on-disk source is missing in tests.
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Source file missing on disk"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_admin_can_slice_any_library_file(self, async_client, auth_setup, library_file_factory):
|
|
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
resp = await async_client.post(
|
|
f"/api/v1/library/files/{file.id}/slice",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
json=self._SLICE_BODY,
|
|
)
|
|
# READ_ALL passes the gate even on another user's file.
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Source file missing on disk"
|
|
|
|
# --- archive slice -----------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_cannot_slice_others_archive(
|
|
self, async_client, auth_setup, archive_factory, printer_factory
|
|
):
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, created_by_id=auth_setup["operator2_user"]["id"])
|
|
resp = await async_client.post(
|
|
f"/api/v1/archives/{archive.id}/slice",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json=self._SLICE_BODY,
|
|
)
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Archive not found"
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_operator_can_slice_own_archive(self, async_client, auth_setup, archive_factory, printer_factory):
|
|
printer = await printer_factory()
|
|
archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
|
|
resp = await async_client.post(
|
|
f"/api/v1/archives/{archive.id}/slice",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
json=self._SLICE_BODY,
|
|
)
|
|
# Past the gate — the archive's source file isn't on disk in tests.
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "Archive source file missing on disk"
|
|
|
|
# --- slice-job polling -------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_slice_job_polling_is_owner_scoped(self, async_client, auth_setup):
|
|
from backend.app.services.slice_dispatch import slice_dispatch
|
|
|
|
async def _noop(_job_id):
|
|
return {}
|
|
|
|
job = await slice_dispatch.enqueue(
|
|
kind="library_file",
|
|
source_id=1,
|
|
source_name="secret_model.3mf",
|
|
owner_id=auth_setup["operator2_user"]["id"],
|
|
run=_noop,
|
|
)
|
|
|
|
# Non-owner without READ_ALL cannot see the job (404, not 403).
|
|
other = await async_client.get(
|
|
f"/api/v1/slice-jobs/{job.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
|
)
|
|
assert other.status_code == 404
|
|
|
|
# The owner and a READ_ALL admin can.
|
|
owner = await async_client.get(
|
|
f"/api/v1/slice-jobs/{job.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
|
|
)
|
|
assert owner.status_code == 200
|
|
admin = await async_client.get(
|
|
f"/api/v1/slice-jobs/{job.id}",
|
|
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
|
)
|
|
assert admin.status_code == 200
|
|
|
|
# --- pipeline source resolution ----------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_pipeline_run_cannot_reference_others_library_file(
|
|
self, async_client, auth_setup, library_file_factory, db_session
|
|
):
|
|
"""A pipeline runner with READ_OWN cannot resolve another user's source.
|
|
|
|
The built-in Operators group has no pipeline permissions, so this uses a
|
|
custom group carrying PIPELINES_RUN + READ_OWN — the realistic shape of
|
|
the exposure. check-eligibility resolves the source before any
|
|
eligibility work, so the ownership gate is what returns 404.
|
|
"""
|
|
from backend.app.models.slicer_pipeline import SlicerPipeline
|
|
|
|
admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
|
|
group_resp = await async_client.post(
|
|
"/api/v1/groups/",
|
|
headers=admin_headers,
|
|
json={
|
|
"name": "pipeline_runners",
|
|
"permissions": [
|
|
"pipelines:read",
|
|
"pipelines:run",
|
|
"library:read_own",
|
|
"archives:read_own",
|
|
],
|
|
},
|
|
)
|
|
assert group_resp.status_code == 201, group_resp.text
|
|
group_id = group_resp.json()["id"]
|
|
|
|
await async_client.post(
|
|
"/api/v1/users/",
|
|
headers=admin_headers,
|
|
json={"username": "runner1", "password": "Runnerpass1!", "group_ids": [group_id]},
|
|
)
|
|
runner_login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "runner1", "password": "Runnerpass1!"},
|
|
)
|
|
runner_token = runner_login.json()["access_token"]
|
|
|
|
pipeline = SlicerPipeline(
|
|
name="Cross-user pipeline",
|
|
printer_preset_source="local",
|
|
printer_preset_id="1",
|
|
process_preset_source="local",
|
|
process_preset_id="2",
|
|
filament_presets_json="[]",
|
|
target_kind="printer_class",
|
|
target_model_class="Bambu Lab X1 Carbon",
|
|
)
|
|
db_session.add(pipeline)
|
|
await db_session.commit()
|
|
await db_session.refresh(pipeline)
|
|
|
|
# Source owned by operator2, not the runner.
|
|
file = await library_file_factory(created_by_id=auth_setup["operator2_user"]["id"])
|
|
resp = await async_client.post(
|
|
f"/api/v1/slicer-pipelines/{pipeline.id}/check-eligibility",
|
|
headers={"Authorization": f"Bearer {runner_token}"},
|
|
json={"source_library_file_id": file.id},
|
|
)
|
|
assert resp.status_code == 404
|
|
assert resp.json()["detail"] == "File not found"
|