mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Two adversarial-input fixtures added on the 0.2.4.6 branch were
missing the # nosec annotation that 32b3a93e established for the
same pattern. New TestNotArmedDiagnosticLogging test for the #1429
defensive diagnostic uses bind_address="0.0.0.0"; new
test_queue_start_user_attribution.py (#1670 fix) uses a /tmp path
in a PrintArchive fixture. Same annotation-only convention as the
test_virtual_printer.py sites.
247 lines
9.8 KiB
Python
247 lines
9.8 KiB
Python
"""Regression tests for #1670: queue manual-start path lost user attribution.
|
|
|
|
Before the fix, a VP-uploaded queue item (created over FTP, so unattributed)
|
|
that was then started by an authenticated user via the `/start` button would
|
|
land in the PrintLogEntry table with `created_by_username = NULL` because
|
|
the scheduler dispatch path never set `current_print_user` and the `/start`
|
|
route didn't record the clicker.
|
|
|
|
The fix is two-sided:
|
|
- `POST /queue/{id}/start` credits the clicker as `created_by_id` when
|
|
no prior owner is set (does NOT overwrite an existing owner — a
|
|
UI-added queue item's original uploader keeps attribution).
|
|
- `PrintScheduler._start_print` propagates `item.created_by_id` into
|
|
`printer_manager.set_current_print_user` so the print-complete callback
|
|
can write the username into the PrintLogEntry row.
|
|
|
|
These tests pin both halves so a future refactor can't silently regress
|
|
either one back to "blank User column."
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from backend.app.models.print_queue import PrintQueueItem
|
|
|
|
|
|
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 session via
|
|
`Depends(get_db)`, so verification reads use a new session against the
|
|
same engine."""
|
|
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(async_client: AsyncClient) -> tuple[str, dict]:
|
|
"""Boot the app's auth setup and return (admin_token, admin_user)."""
|
|
await async_client.post(
|
|
"/api/v1/auth/setup",
|
|
json={
|
|
"auth_enabled": True,
|
|
"admin_username": "queue1670admin",
|
|
"admin_password": "AdminPass1!",
|
|
},
|
|
)
|
|
login = await async_client.post(
|
|
"/api/v1/auth/login",
|
|
json={"username": "queue1670admin", "password": "AdminPass1!"},
|
|
)
|
|
body = login.json()
|
|
return body["access_token"], body["user"]
|
|
|
|
|
|
@pytest.fixture
|
|
async def queue_item(db_session):
|
|
"""A pending, manual-start, UNATTRIBUTED queue item — mirrors what the
|
|
VP-queue path produces (FTP upload has no user, manual_start is the
|
|
Queue-mode default)."""
|
|
from backend.app.models.archive import PrintArchive
|
|
from backend.app.models.printer import Printer
|
|
|
|
printer = Printer(
|
|
name="P2S Test",
|
|
ip_address="192.168.2.201",
|
|
serial_number="00M00A1234567890",
|
|
access_code="12345678",
|
|
model="P2S",
|
|
)
|
|
db_session.add(printer)
|
|
await db_session.commit()
|
|
await db_session.refresh(printer)
|
|
|
|
archive = PrintArchive(
|
|
filename="Plate_1.gcode.3mf",
|
|
print_name="Plate 1",
|
|
file_path="/tmp/queue1670_plate.3mf", # nosec B108
|
|
file_size=1024,
|
|
content_hash="queue1670hash",
|
|
status="completed",
|
|
)
|
|
db_session.add(archive)
|
|
await db_session.commit()
|
|
await db_session.refresh(archive)
|
|
|
|
item = PrintQueueItem(
|
|
printer_id=printer.id,
|
|
archive_id=archive.id,
|
|
status="pending",
|
|
position=1,
|
|
manual_start=True,
|
|
created_by_id=None, # unattributed — VP-queue shape
|
|
)
|
|
db_session.add(item)
|
|
await db_session.commit()
|
|
await db_session.refresh(item)
|
|
return item
|
|
|
|
|
|
class TestStartCreditsTheClicker:
|
|
"""`/start` writes the clicker's id to `created_by_id` when none was set."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_start_writes_created_by_id_when_unattributed(
|
|
self, async_client: AsyncClient, test_engine, queue_item
|
|
):
|
|
admin_token, admin_user = await _enable_auth_with_admin(async_client)
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{queue_item.id}/start",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
refreshed = await _read_item(test_engine, queue_item.id)
|
|
assert refreshed.created_by_id == admin_user["id"]
|
|
assert refreshed.manual_start is False
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_start_preserves_existing_owner(self, async_client: AsyncClient, db_session, test_engine, queue_item):
|
|
"""A queue item that was created by user A and then started by user B
|
|
keeps user A's attribution — the original uploader's claim is stronger
|
|
than the dispatcher's. (Matches the standard ownership semantics in
|
|
`auth.py::require_ownership_permission`.)"""
|
|
admin_token, admin_user = await _enable_auth_with_admin(async_client)
|
|
|
|
# Pre-set a different owner on the queue item using a fresh session
|
|
# (the test's `db_session` is detached from the route's session pool).
|
|
prior_owner_id = admin_user["id"] + 9999
|
|
maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
|
async with maker() as fresh:
|
|
item = (await fresh.execute(select(PrintQueueItem).where(PrintQueueItem.id == queue_item.id))).scalar_one()
|
|
item.created_by_id = prior_owner_id
|
|
await fresh.commit()
|
|
|
|
response = await async_client.post(
|
|
f"/api/v1/queue/{queue_item.id}/start",
|
|
headers={"Authorization": f"Bearer {admin_token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
refreshed = await _read_item(test_engine, queue_item.id)
|
|
# Prior owner survives — `/start` did not promote the clicker.
|
|
assert refreshed.created_by_id == prior_owner_id
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_start_with_auth_disabled_leaves_created_by_id_null(
|
|
self, async_client: AsyncClient, test_engine, queue_item
|
|
):
|
|
"""When auth is off the route's user dep returns None — the item stays
|
|
unattributed (no synthetic 'system' user invented). Regression guard
|
|
in case a future refactor accidentally invents a placeholder user id."""
|
|
response = await async_client.post(f"/api/v1/queue/{queue_item.id}/start")
|
|
assert response.status_code == 200
|
|
|
|
refreshed = await _read_item(test_engine, queue_item.id)
|
|
assert refreshed.created_by_id is None
|
|
|
|
|
|
class TestSchedulerPropagatesOwnerToPrinterManager:
|
|
"""`PrintScheduler._propagate_owner_to_printer_manager` looks up the
|
|
user row by `created_by_id` and forwards it into
|
|
`printer_manager.set_current_print_user` so the print-complete callback
|
|
can write the username into PrintLogEntry."""
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_propagates_when_created_by_id_resolves_to_user(self, db_session, queue_item, monkeypatch):
|
|
from backend.app.models.user import User
|
|
from backend.app.services import print_scheduler as scheduler_module
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
user = User(username="clickeruser", password_hash="x", is_active=True)
|
|
db_session.add(user)
|
|
await db_session.commit()
|
|
await db_session.refresh(user)
|
|
|
|
queue_item.created_by_id = user.id
|
|
db_session.add(queue_item)
|
|
await db_session.commit()
|
|
await db_session.refresh(queue_item)
|
|
|
|
captured: list[tuple[int, int, str]] = []
|
|
monkeypatch.setattr(
|
|
scheduler_module.printer_manager,
|
|
"set_current_print_user",
|
|
lambda printer_id, uid, username: captured.append((printer_id, uid, username)),
|
|
)
|
|
|
|
await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
|
|
|
|
assert captured == [(queue_item.printer_id, user.id, "clickeruser")]
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_noop_when_created_by_id_is_none(self, db_session, queue_item, monkeypatch):
|
|
"""VP-uploaded queue items that never got manual-started (e.g.
|
|
auto-dispatch) carry no owner — the helper must stay silent rather
|
|
than synthesise a placeholder user."""
|
|
from backend.app.services import print_scheduler as scheduler_module
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
assert queue_item.created_by_id is None
|
|
|
|
captured: list = []
|
|
monkeypatch.setattr(
|
|
scheduler_module.printer_manager,
|
|
"set_current_print_user",
|
|
lambda *args: captured.append(args),
|
|
)
|
|
|
|
await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
|
|
assert captured == []
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
async def test_noop_when_user_row_missing(self, db_session, queue_item, monkeypatch):
|
|
"""`created_by_id` points at a user that's since been deleted —
|
|
helper must not crash the dispatch. The print log row will just be
|
|
un-credited for this run, same as auth-disabled."""
|
|
from backend.app.services import print_scheduler as scheduler_module
|
|
from backend.app.services.print_scheduler import PrintScheduler
|
|
|
|
queue_item.created_by_id = 999_999 # no such user row
|
|
db_session.add(queue_item)
|
|
await db_session.commit()
|
|
await db_session.refresh(queue_item)
|
|
|
|
captured: list = []
|
|
monkeypatch.setattr(
|
|
scheduler_module.printer_manager,
|
|
"set_current_print_user",
|
|
lambda *args: captured.append(args),
|
|
)
|
|
|
|
# Must not raise — the dispatch loop would otherwise lose the whole
|
|
# queue item to an exception trace for what's effectively a missing
|
|
# foreign key.
|
|
await PrintScheduler()._propagate_owner_to_printer_manager(db_session, queue_item)
|
|
assert captured == []
|