mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Merge branch 'dev' into feature/upload-prefer-filename-for-name
This commit is contained in:
@@ -534,6 +534,103 @@ class TestSliceLibraryFile:
|
||||
assert "Metadata/cut_information.xml" in names
|
||||
assert "3D/3dmodel.model" in names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_use_embedded_settings_skips_profile_triplet(
|
||||
self, async_client: AsyncClient, db_session, slice_test_setup
|
||||
):
|
||||
# "Slice as designed" (#2611): with use_embedded_settings the 3MF is
|
||||
# sliced on its own project_settings.config — no --load-settings — so
|
||||
# the sidecar request carries ONLY the model file, never the
|
||||
# printer/process/filament profile parts. Succeeds on the first call
|
||||
# (no crash-fallback), and the result is flagged used_embedded_settings.
|
||||
src_3mf_path = slice_test_setup["tmp_path"] / "library" / "files" / "designed.3mf"
|
||||
src_3mf_path.write_bytes(_make_3mf_with_settings({"wall_loops": "5"}))
|
||||
threemf = LibraryFile(
|
||||
filename="designed.3mf",
|
||||
file_path=str(src_3mf_path.relative_to(slice_test_setup["tmp_path"])),
|
||||
file_type="3mf",
|
||||
file_size=src_3mf_path.stat().st_size,
|
||||
)
|
||||
db_session.add(threemf)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(threemf)
|
||||
|
||||
captured: dict = {}
|
||||
call_count = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
call_count["n"] += 1
|
||||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake-3mf",
|
||||
headers={
|
||||
"x-print-time-seconds": "100",
|
||||
"x-filament-used-g": "1.0",
|
||||
"x-filament-used-mm": "100",
|
||||
},
|
||||
)
|
||||
|
||||
_install_mock_sidecar(handler)
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{threemf.id}/slice",
|
||||
json={
|
||||
"printer_preset_id": slice_test_setup["printer_id"],
|
||||
"process_preset_id": slice_test_setup["process_id"],
|
||||
"filament_preset_id": slice_test_setup["filament_id"],
|
||||
"use_embedded_settings": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
final = await _wait_for_job(async_client, response.json()["job_id"])
|
||||
assert final["status"] == "completed", final
|
||||
assert final["result"]["used_embedded_settings"] is True
|
||||
assert call_count["n"] == 1 # embedded path taken directly, no fallback retry
|
||||
|
||||
# The multipart body must NOT carry any profile part — that is the
|
||||
# whole point of the mode. Their presence would mean --load-settings
|
||||
# ran and overrode the designer's embedded settings.
|
||||
body = captured["body"]
|
||||
assert b"printerProfile" not in body
|
||||
assert b"presetProfile" not in body
|
||||
assert b"filamentProfile" not in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_use_embedded_settings_ignored_for_stl(self, async_client: AsyncClient, slice_test_setup):
|
||||
# An STL has no embedded project settings to honour, so the flag is a
|
||||
# no-op: the normal profile path runs and the triplet is forwarded.
|
||||
captured: dict = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake-3mf",
|
||||
headers={
|
||||
"x-print-time-seconds": "1",
|
||||
"x-filament-used-g": "0",
|
||||
"x-filament-used-mm": "0",
|
||||
},
|
||||
)
|
||||
|
||||
_install_mock_sidecar(handler)
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
|
||||
json={
|
||||
"printer_preset_id": slice_test_setup["printer_id"],
|
||||
"process_preset_id": slice_test_setup["process_id"],
|
||||
"filament_preset_id": slice_test_setup["filament_id"],
|
||||
"use_embedded_settings": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 202
|
||||
final = await _wait_for_job(async_client, response.json()["job_id"])
|
||||
assert final["status"] == "completed", final
|
||||
assert final["result"]["used_embedded_settings"] is False
|
||||
assert b"printerProfile" in captured["body"] # profile path still ran
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /slice-jobs/{id}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Integration tests for the token-authenticated streaming-overlay feed (#2613).
|
||||
|
||||
Like the Cam Wall feed, the overlay endpoint exists as its own scope-gated
|
||||
route because a kiosk/OBS URL is not a secret. But it is deliberately *wider*
|
||||
than the Cam Wall: it names the file being printed (the overlay draws the part
|
||||
on screen). So the tests that matter are the scope boundaries — an overlay
|
||||
token must not reach the Cam Wall feed and vice versa, a camwall token must not
|
||||
reach the overlay feed (that would leak the filename it is trusted to hide) —
|
||||
plus the positive path and the disconnected-printer shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": f"overlayadmin{suffix}",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
json={"name": name, "expires_in_days": 30, "scope": scope},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["scope"] == scope
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_row(db_session):
|
||||
"""Insert the printer straight into the DB.
|
||||
|
||||
POST /printers probes the real device before it will store a row, and there
|
||||
is no printer on the other end of a test run.
|
||||
"""
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
printer = Printer(
|
||||
name="Stream P1S",
|
||||
ip_address="192.168.1.88",
|
||||
access_code="12345678",
|
||||
serial_number="01P00A000000002",
|
||||
model="P1S",
|
||||
)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
return printer
|
||||
|
||||
|
||||
class TestOverlayFeedAuth:
|
||||
async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
await _setup_admin(async_client, suffix="_notoken")
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
await _setup_admin(async_client, suffix="_garbage")
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
|
||||
"""A ``camera_stream`` token was handed out for video alone — it must not
|
||||
acquire the live print status (and filename) just because a new feature
|
||||
shipped.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_streamscope")
|
||||
stream_token = await _mint(async_client, jwt, scope="camera_stream")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
|
||||
"""The crux of a *separate* scope from camwall.
|
||||
|
||||
A Cam Wall token is trusted precisely because it can never name the part
|
||||
being printed. The overlay feed does name it, so a camwall token must be
|
||||
rejected here — otherwise every wall token silently gains filename
|
||||
visibility.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_camwallscope")
|
||||
camwall_token = await _mint(async_client, jwt, scope="camwall")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
|
||||
jwt = await _setup_admin(async_client, suffix="_rightscope")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["name"] == "Stream P1S"
|
||||
|
||||
async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
jwt = await _setup_admin(async_client, suffix="_revoked")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
json={"name": "obs", "expires_in_days": 30, "scope": "overlay"},
|
||||
)
|
||||
overlay_token = created.json()["token"]
|
||||
await async_client.delete(
|
||||
f"/api/v1/auth/tokens/{created.json()['id']}",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestOverlayFeedPayload:
|
||||
async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row):
|
||||
"""Unlike the Cam Wall, the overlay *does* carry the filename fields —
|
||||
that is what distinguishes the scope. Assert the exact key set so the
|
||||
payload can't silently grow to leak more than the overlay draws.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_payload")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 200
|
||||
entry = response.json()
|
||||
|
||||
# Never the secrets — the URL is on a public stream.
|
||||
for leaked in ("serial_number", "ip_address", "access_code"):
|
||||
assert leaked not in entry, f"{leaked} must not be served to an overlay token"
|
||||
|
||||
assert set(entry) == {
|
||||
"id",
|
||||
"name",
|
||||
"camera_rotation",
|
||||
"connected",
|
||||
"state",
|
||||
"current_print",
|
||||
"gcode_file",
|
||||
"progress",
|
||||
"remaining_time",
|
||||
"layer_num",
|
||||
"total_layers",
|
||||
"stg_cur_name",
|
||||
"time_format",
|
||||
}
|
||||
|
||||
async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
|
||||
"""No MQTT client runs in tests, so the printer has no state — the
|
||||
overlay must render its offline state rather than erroring.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_offline")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
entry = response.json()
|
||||
assert entry["connected"] is False
|
||||
assert entry["state"] is None
|
||||
assert entry["current_print"] is None
|
||||
|
||||
async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
|
||||
"""A valid token for a printer id that doesn't exist is a 404 — the token
|
||||
passed the gate, the resource simply isn't there.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_404")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestOverlayTokenReachesTheVideo:
|
||||
"""The overlay draws the camera feed, so the same token has to satisfy the
|
||||
camera-stream gate.
|
||||
"""
|
||||
|
||||
async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_camera_stream_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_video")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
assert await verify_camera_stream_token(overlay_token) is True
|
||||
|
||||
async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_overlay_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_gate")
|
||||
stream_token = await _mint(async_client, jwt, scope="camera_stream")
|
||||
camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
|
||||
|
||||
assert await verify_overlay_token(stream_token) is False
|
||||
assert await verify_overlay_token(camwall_token) is False
|
||||
|
||||
async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
|
||||
"""Symmetric guard: the new scope must not widen the Cam Wall either."""
|
||||
from backend.app.core.auth import verify_camwall_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_gate_camwall")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
assert await verify_camwall_token(overlay_token) is False
|
||||
@@ -333,6 +333,57 @@ class TestPrintQueueAPI:
|
||||
assert result["bed_levelling"] is False
|
||||
assert result["timelapse"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reassign_rejected_while_dispatching(
|
||||
self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
|
||||
):
|
||||
"""#2615: a claimed (in-flight) row rejects edits with 409, so its printer
|
||||
can't be reassigned out from under the running FTP upload."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
|
||||
other = await printer_factory()
|
||||
original_printer_id = item.printer_id
|
||||
|
||||
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"printer_id": other.id})
|
||||
assert response.status_code == 409
|
||||
|
||||
await db_session.refresh(item)
|
||||
assert item.printer_id == original_printer_id, "printer_id must not change on a dispatching row"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_update_skips_dispatching_item(
|
||||
self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
|
||||
):
|
||||
"""#2615: bulk edits skip a claimed row rather than splitting it."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
|
||||
other = await printer_factory()
|
||||
original_printer_id = item.printer_id
|
||||
|
||||
response = await async_client.patch("/api/v1/queue/bulk", json={"item_ids": [item.id], "printer_id": other.id})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["skipped_count"] == 1
|
||||
assert body["updated_count"] == 0
|
||||
|
||||
await db_session.refresh(item)
|
||||
assert item.printer_id == original_printer_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_allowed_on_unclaimed_pending_item(
|
||||
self, async_client: AsyncClient, queue_item_factory, db_session
|
||||
):
|
||||
"""Regression guard: a normal pending row (no claim) still edits fine."""
|
||||
item = await queue_item_factory()
|
||||
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 7})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["plate_id"] == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
|
||||
Reference in New Issue
Block a user