"""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.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 owner = User(username="keyowner", password_hash="x", is_active=True) 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