|
@@ -1112,3 +1112,113 @@ class TestLibraryPermissions:
|
|
|
)
|
|
)
|
|
|
# Viewers don't have delete_own or delete_all permissions
|
|
# Viewers don't have delete_own or delete_all permissions
|
|
|
assert response.status_code == 403
|
|
assert response.status_code == 403
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class TestPrintFileUploadValidation:
|
|
|
|
|
+ """#1401: pre-flight rejection of unprintable uploads at the library +
|
|
|
|
|
+ archive routes. Smoke tests the shared ``validate_print_file_upload``
|
|
|
|
|
+ helper through both surfaces a user can reach with a drag-drop."""
|
|
|
|
|
+
|
|
|
|
|
+ def _valid_3mf_bytes(self, name: str = "Metadata/plate_1.gcode") -> bytes:
|
|
|
|
|
+ """Build a minimal-but-real zip with the gcode-3mf magic in it so
|
|
|
|
|
+ the validator's ``startswith(b"PK\\x03\\x04")`` check passes."""
|
|
|
|
|
+ buf = io.BytesIO()
|
|
|
|
|
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
|
|
|
+ zf.writestr(name, "; G-code\nG28\n")
|
|
|
|
|
+ return buf.getvalue()
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ @pytest.mark.integration
|
|
|
|
|
+ async def test_library_rejects_raw_gcode_upload(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """``Foo.gcode`` direct uploads are blocked at the library route —
|
|
|
|
|
+ the dispatcher would otherwise append ``.3mf`` and ship raw gcode
|
|
|
|
|
+ to the printer as a fake 3MF."""
|
|
|
|
|
+ files = {"file": ("plate_1.gcode", b"; raw gcode\nG28\n", "application/octet-stream")}
|
|
|
|
|
+ response = await async_client.post("/api/v1/library/files", files=files)
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ # Error message must name the actual remedy, not just say "invalid".
|
|
|
|
|
+ assert "gcode.3mf" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ @pytest.mark.integration
|
|
|
|
|
+ async def test_library_rejects_non_zip_3mf_upload(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """A ``.3mf`` upload whose body isn't a zip is rejected — covers
|
|
|
|
|
+ raw gcode renamed to .3mf, corrupted downloads, etc."""
|
|
|
|
|
+ files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
|
|
|
|
|
+ response = await async_client.post("/api/v1/library/files", files=files)
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "ZIP container" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ @pytest.mark.integration
|
|
|
|
|
+ async def test_library_rejects_non_zip_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """The compound-extension ``.gcode.3mf`` case is gated by the same
|
|
|
|
|
+ zip-magic check — splitext returns just ``.3mf``, but the suffix
|
|
|
|
|
+ match covers both."""
|
|
|
|
|
+ files = {"file": ("plate_1.gcode.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
|
|
|
|
|
+ response = await async_client.post("/api/v1/library/files", files=files)
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "ZIP container" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ @pytest.mark.integration
|
|
|
|
|
+ async def test_library_accepts_valid_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """A real ``.gcode.3mf`` zip uploads successfully — the existing
|
|
|
|
|
+ happy path is not regressed by the new validation."""
|
|
|
|
|
+ files = {
|
|
|
|
|
+ "file": (
|
|
|
|
|
+ "plate_1.gcode.3mf",
|
|
|
|
|
+ self._valid_3mf_bytes(),
|
|
|
|
|
+ "application/zip",
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ response = await async_client.post("/api/v1/library/files", files=files)
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ result = response.json()
|
|
|
|
|
+ assert result["filename"] == "plate_1.gcode.3mf"
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ @pytest.mark.integration
|
|
|
|
|
+ async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """STL / image / other non-print uploads bypass the validator
|
|
|
|
|
+ entirely — Bambuddy is also a library, not just a print dispatcher."""
|
|
|
|
|
+ files = {"file": ("model.stl", b"solid test\nendsolid test", "application/octet-stream")}
|
|
|
|
|
+ response = await async_client.post(
|
|
|
|
|
+ "/api/v1/library/files", files=files, params={"generate_stl_thumbnails": "false"}
|
|
|
|
|
+ )
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ @pytest.mark.integration
|
|
|
|
|
+ async def test_archive_upload_rejects_non_zip(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """``POST /archives/upload`` shares the same validator — covers the
|
|
|
|
|
+ manual archive-upload entry point too."""
|
|
|
|
|
+ files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
|
|
|
|
|
+ response = await async_client.post("/api/v1/archives/upload", files=files)
|
|
|
|
|
+ assert response.status_code == 400
|
|
|
|
|
+ assert "ZIP container" in response.json()["detail"]
|
|
|
|
|
+
|
|
|
|
|
+ @pytest.mark.asyncio
|
|
|
|
|
+ @pytest.mark.integration
|
|
|
|
|
+ async def test_archive_bulk_upload_collects_per_file_errors(self, async_client: AsyncClient, db_session):
|
|
|
|
|
+ """The bulk-archive route reports validation failures per file and
|
|
|
|
|
+ continues processing the remaining items — one bad upload in a
|
|
|
|
|
+ 10-file drag-drop must not abort the whole batch."""
|
|
|
|
|
+ good = self._valid_3mf_bytes()
|
|
|
|
|
+ bad = b"; raw gcode\nG28\n"
|
|
|
|
|
+ # httpx multipart with a list-of-tuples preserves order + same field name.
|
|
|
|
|
+ files = [
|
|
|
|
|
+ ("files", ("good.3mf", good, "application/zip")),
|
|
|
|
|
+ ("files", ("bad.3mf", bad, "application/octet-stream")),
|
|
|
|
|
+ ]
|
|
|
|
|
+ response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
|
|
|
|
|
+ assert response.status_code == 200
|
|
|
|
|
+ body = response.json()
|
|
|
|
|
+ # The bulk route's archive_print may still reject the "good" file
|
|
|
|
|
+ # downstream (no printer match, etc.) — we don't care about that
|
|
|
|
|
+ # here; what matters is the bad file lands in `errors` with the
|
|
|
|
|
+ # validator's message and the route didn't 500.
|
|
|
|
|
+ assert body["failed"] >= 1
|
|
|
|
|
+ bad_errors = [e for e in body["errors"] if e["filename"] == "bad.3mf"]
|
|
|
|
|
+ assert bad_errors, body
|
|
|
|
|
+ assert "ZIP container" in bad_errors[0]["error"]
|