mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Archives, the queue and statistics report ownership as a numeric created_by_id, and statistics accept it as a filter, but nothing let an API key discover whose id was whose -- the only user listing returns emails, roles, group membership and full permission sets, so it is administrative and rejects keys. Add GET /users/slim returning id + username only, gated on a new users:read_slim permission mapped to can_read_status. That grants no data a key could not already reach: for API-keyed requests the permission deps return None as current_user, so the stats:filter_by_user guard short-circuits and ?created_by_id=N is already honoured for every N. What was missing was the ability to address the filter, not permission to use it. The full listing stays unmapped = admin-only. Also fix /auth/me, which answered an API key with a synthetic administrator: id 0, role admin, is_admin true and every permission in the enum. A key cannot reach an administrative route at all, so clients building their UI from that response rendered actions that 403 on use. It now reports the key owner's identity, is_admin false, and the permissions the key's scopes actually admit. Ownerless legacy keys keep id 0 but no longer claim admin. --- Source user names from the slim listing where only names are needed (#1894) Stats filter-by-user, the Archives print log filter, the File Manager username autocomplete, the camera-token owner column and the Finance member picker all render nothing but a username, but all of them read the full user listing, which is gated on the admin-level users:read. An operator granted stats:filter_by_user but not users:read got an empty filter with no indication why. Point them at /users/slim under a separate react-query key, since the full listing shares the 'users' key and the two shapes would clobber each other in the cache.
219 lines
8.6 KiB
Python
219 lines
8.6 KiB
Python
"""Regression tests: queue items created outside POST /queue/ lost their owner.
|
|
|
|
`PrintQueueItem.created_by_id` is what the `queue:read_own` / `queue:update_own` /
|
|
`queue:delete_own` permissions filter on (`api/routes/print_queue.py`). Two
|
|
creation paths never set it, so the rows they produced were ownerless:
|
|
|
|
- `POST /library/files/add-to-queue` — the bulk "Add to queue" action on the
|
|
Library page. The route already required `Permission.QUEUE_CREATE` but bound
|
|
the dependency to `_` and threw the user away, so a non-admin who queued
|
|
files from the Library could not then see them in their own queue.
|
|
- `POST /webhook/queue/add` — API-key inbound. `APIKey.user_id` records the
|
|
key's owner, which is the acting identity for everything else the key does.
|
|
|
|
Ownerless rows are still legitimate for callers with no user behind them (auth
|
|
disabled, virtual-printer FTP uploads, legacy keys minted before per-user
|
|
ownership), so the tests pin those cases too — the fix must not invent a
|
|
placeholder user id. Compare `test_queue_start_user_attribution.py`, which pins
|
|
the same NULL-is-meaningful contract on the `/start` path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from backend.app.core.auth import generate_api_key
|
|
from backend.app.core.config import settings as app_settings
|
|
from backend.app.models.api_key import APIKey
|
|
from backend.app.models.group import Group
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
from backend.app.models.user import User
|
|
|
|
|
|
async def _read_item(test_engine, item_id: int) -> PrintQueueItem:
|
|
"""Fresh-session DB read — the `db_session` fixture's connection can look
|
|
stale after a route call dispatches through its own `Depends(get_db)`
|
|
session. Same helper shape as test_queue_start_user_attribution.py."""
|
|
maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
|
async with maker() as fresh:
|
|
return (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
|
|
|
|
|
|
async def _enable_auth_with_admin(client: AsyncClient, username: str) -> tuple[str, dict]:
|
|
"""Boot auth setup and return (bearer_token, user_dict)."""
|
|
await client.post(
|
|
"/api/v1/auth/setup",
|
|
json={
|
|
"auth_enabled": True,
|
|
"admin_username": username,
|
|
"admin_password": "AdminPass1!",
|
|
},
|
|
)
|
|
login = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": username, "password": "AdminPass1!"},
|
|
)
|
|
body = login.json()
|
|
return body["access_token"], body["user"]
|
|
|
|
|
|
@pytest.fixture
|
|
async def sliced_library_file(db_session):
|
|
"""A library file that passes both of add-to-queue's gates: the filename
|
|
must look sliced, and the bytes must actually exist under `base_dir` (the
|
|
route rejects rows whose file is missing from disk)."""
|
|
from backend.app.models.library import LibraryFile
|
|
|
|
rel_path = "archive/library/files/attribution_probe.gcode.3mf"
|
|
abs_path = Path(app_settings.base_dir) / rel_path
|
|
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
abs_path.write_bytes(b"probe")
|
|
|
|
lib_file = LibraryFile(
|
|
filename="attribution_probe.gcode.3mf",
|
|
file_path=rel_path,
|
|
file_size=5,
|
|
file_type="3mf",
|
|
)
|
|
db_session.add(lib_file)
|
|
await db_session.commit()
|
|
await db_session.refresh(lib_file)
|
|
|
|
yield lib_file
|
|
|
|
abs_path.unlink(missing_ok=True)
|
|
|
|
|
|
class TestLibraryAddToQueueAttribution:
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_credits_the_authenticated_user(self, async_client: AsyncClient, test_engine, sliced_library_file):
|
|
"""The Library bulk-add is the path a user reaches for when queueing
|
|
many files at once — exactly the case where losing attribution hurts."""
|
|
token, user = await _enable_auth_with_admin(async_client, "libqueueadmin")
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/library/files/add-to-queue",
|
|
json={"file_ids": [sliced_library_file.id]},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
added = response.json()["added"]
|
|
assert len(added) == 1
|
|
|
|
item = await _read_item(test_engine, added[0]["queue_item_id"])
|
|
assert item.created_by_id == user["id"]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_auth_disabled_leaves_item_ownerless(
|
|
self, async_client: AsyncClient, test_engine, sliced_library_file
|
|
):
|
|
"""With auth off the permission dep yields None. The row must stay
|
|
NULL rather than gaining a synthetic owner."""
|
|
response = await async_client.post(
|
|
"/api/v1/library/files/add-to-queue",
|
|
json={"file_ids": [sliced_library_file.id]},
|
|
)
|
|
assert response.status_code == 200
|
|
added = response.json()["added"]
|
|
assert len(added) == 1
|
|
|
|
item = await _read_item(test_engine, added[0]["queue_item_id"])
|
|
assert item.created_by_id is None
|
|
|
|
|
|
class TestWebhookQueueAddAttribution:
|
|
@pytest.fixture
|
|
async def printer_and_archive(self, db_session):
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.printer import Printer
|
|
|
|
printer = Printer(
|
|
name="Webhook Target",
|
|
ip_address="192.168.2.202",
|
|
serial_number="00M00A9876543210",
|
|
access_code="12345678",
|
|
model="P1S",
|
|
)
|
|
archive = PrintArchive(
|
|
filename="Plate_1.gcode.3mf",
|
|
print_name="Plate 1",
|
|
file_path="/tmp/webhook_attribution.3mf", # nosec B108
|
|
file_size=1024,
|
|
content_hash="webhookattributionhash",
|
|
status="completed",
|
|
)
|
|
db_session.add_all([printer, archive])
|
|
await db_session.commit()
|
|
await db_session.refresh(printer)
|
|
await db_session.refresh(archive)
|
|
return printer, archive
|
|
|
|
async def _mint_key(self, db_session, owner_id: int | None) -> str:
|
|
full_key, key_hash, key_prefix = generate_api_key()
|
|
db_session.add(
|
|
APIKey(
|
|
name="attribution probe",
|
|
key_hash=key_hash,
|
|
key_prefix=key_prefix,
|
|
user_id=owner_id,
|
|
can_queue=True,
|
|
)
|
|
)
|
|
await db_session.commit()
|
|
return full_key
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive):
|
|
printer, archive = printer_and_archive
|
|
|
|
# The owner needs queue:create in their own right: a key is capped by
|
|
# its owner's permissions (#1894), so a bare account with no groups
|
|
# cannot queue through a key however its scope flags are set. This test
|
|
# is about who the row is credited to, not about the gate.
|
|
group = Group(name="queue-writers", description="t", permissions=["queue:create"], is_system=False)
|
|
db_session.add(group)
|
|
await db_session.flush()
|
|
owner = User(username="keyowner", password_hash="x", is_active=True, groups=[group])
|
|
db_session.add(owner)
|
|
await db_session.commit()
|
|
await db_session.refresh(owner)
|
|
|
|
key = await self._mint_key(db_session, owner.id)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/webhook/queue/add",
|
|
json={"printer_id": printer.id, "archive_id": archive.id},
|
|
headers={"X-API-Key": key},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
item = await _read_item(test_engine, response.json()["id"])
|
|
assert item.created_by_id == owner.id
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_legacy_ownerless_key_leaves_item_ownerless(
|
|
self, async_client: AsyncClient, db_session, test_engine, printer_and_archive
|
|
):
|
|
"""`APIKey.user_id` is nullable only for keys minted before per-user
|
|
ownership existed. Those must produce an ownerless row, not a crash."""
|
|
printer, archive = printer_and_archive
|
|
key = await self._mint_key(db_session, None)
|
|
|
|
response = await async_client.post(
|
|
"/api/v1/webhook/queue/add",
|
|
json={"printer_id": printer.id, "archive_id": archive.id},
|
|
headers={"X-API-Key": key},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
item = await _read_item(test_engine, response.json()["id"])
|
|
assert item.created_by_id is None
|