diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e21bb55f..4cbfa2e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ All notable changes to Bambuddy will be documented in this file. - **"Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters** ([#1059](https://github.com/maziggy/bambuddy/issues/1059)) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file: `.3mf` files opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes); `.stl` files greyed the button out; `.step` couldn't be previewed at all. The protocol-handler URL emitted by `frontend/src/utils/slicer.ts` for OrcaSlicer (`orcaslicer://open?file=`) and Windows/Linux Bambu Studio (`bambustudio://open?file=`) was built by plain string concatenation with no `encodeURIComponent()` — the macOS `bambustudioopen://` branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself calls `url_decode()` on the received query (BS `post_init()` calls `url_decode` then `split_str`; OrcaSlicer's Downloader regex-extracts then `url_decode`). Any already-percent-encoded character in the download URL — most commonly `%20` from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms now `encodeURIComponent()` the file URL, so the slicer sees the correctly-encoded URL after its own `url_decode`. The comment block is corrected to document the actual invariant. Regression test in `slicer.test.ts` feeds the exact issue reproduction URL (`Toothpick%20Launcher%20Print-in-Place.3mf`) and asserts `%2520` appears in the generated `orcaslicer://` href — so any future refactor that drops the encoding fails CI. Thanks to @jsapede for the double-encoding diagnosis and @AllanonBrooks and @lunaticds for the original reports. -## [0.2.3.2] - 2020-04-22 +## [0.2.3.2] - 2026-04-22 ### Improved - **GCode Viewer Reshaped as an Archive Preview Tool** ([#963](https://github.com/maziggy/bambuddy/pull/963) follow-up) — PR #963 landed the embedded PrettyGCode viewer with a library file picker, a connected-printer selector with live WebSocket status, and auto-load of the currently-printing file. In practice those three didn't match Bambuddy's data model: the library file picker only listed `.gcode` files (Bambuddy stores `.gcode.3mf`), the printer selector wasn't useful when the real goal is previewing an existing archive, and the auto-load path had the same `.gcode`-filter gap as the picker. The viewer is now scoped to a single focused workflow — "show me the G-code for this archive" — reached from the Archives page 3D-preview button (menu item + the card-corner badge + list-row menu, all three paths navigate the same way). Entry URL is `/gcode-viewer?archive=[&plate=]`; the route falls through to the SPA catch-all so a full-page reload keeps the Bambuddy layout shell, with the iframe at `/gcode-viewer/?archive=…` serving the raw viewer. Bed size is fetched from `GET /archives/{id}/capabilities.build_volume` (already parsing `printable_area` + `printable_height` from the 3MF's `Metadata/project_settings.config`) so any printer model renders the correct bed — 350×320×325 for H2D etc. — with no hardcoded per-model map to maintain. Multi-plate archives now surface a dedicated plate picker modal (`components/PlatePickerModal.tsx`) with thumbnails and object lists matching the existing Re-print modal's visual language; source-only 3MFs (no sliced gcode) show a `archives.platePicker.noGcode` toast instead of sending the user to an empty viewer. Behind the scenes: `GET /archives/{id}/gcode` accepts `?plate=N` and resolves the filename by integer-matching the suffix (zero-padded names like `Metadata/plate_01.gcode` now resolve as plate 1, fixing a class of picker-claimed-but-404 archives); `GET /archives/{id}/plates` gained a top-level `has_gcode: bool` flag so the frontend can suppress the picker when the archive is source-only; `printer_state_to_dict` now injects `name` and `model` into every WebSocket snapshot so consumers don't race a separate `/printers` fetch for proper labels. Removed from the viewer: printer selector + WS subscription, library file picker, `BAMBU_BED_SIZES` hardcoded map, auto-load-currently-printing, sidebar nav entry, 32 orphaned `gcodeViewer` locale keys, and the unreachable `ModelViewerModal` render paths on archive cards (the File Manager still uses `ModelViewerModal` for library file previews — scope preserved). Added test coverage: `?plate=N` happy path, zero-padded filename resolution, missing-plate 404, no-plate fallback to first, `?plate=0` 400 rejection, `has_gcode=true/false` branch, plus `PlatePickerModal.test.tsx` (6 tests covering render, plate-name label, onSelect payload, backdrop close, thumbnail fallback) and `printer_state_to_dict` name/model surfacing tests. A toast replaces the old silent empty viewer for source-only archives; reload stays in the Bambuddy layout; H2D previews no longer overflow the bed. @@ -60,7 +60,20 @@ All notable changes to Bambuddy will be documented in this file. - **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-pr -## [0.2.3.1] - 2020-04-20 +### Added +- **Spoolman Unified Inventory UI** — Replaced the Spoolman iframe with a native inventory UI that matches the local spool experience exactly. The Filament Inventory page auto-detects the active backend (local DB or Spoolman) and renders spools, filters, deep-links, and NFC write flows identically regardless of source. Spoolman spools are fully editable — material, weight, colour, storage location, cost — via a PATCH proxy that re-links the Spoolman filament on metadata changes. Bulk-create, archive, restore, and delete are all supported. A 207 Multi-Status response on partial bulk-create includes `requested_count` and `failed_count` so the UI can surface a useful "Created N of M" message. +- **Storage Location field** — Spoolman's `location` field is now exposed as `storage_location` in the unified inventory schema and editable from the spool detail panel. +- **Deep-link from AMS slot hover card** — Clicking the filament chip on an AMS slot hover card deep-links directly to the matching spool in the inventory, whether it lives in the local DB or Spoolman. The link resolves by spool ID so it survives list reloads; a 404 shows a "Spool not found" toast instead of a generic error. +- **Spoolman-aware NFC write** — The SpoolBuddy Write Tag flow now falls back to Spoolman when a spool is not in the local DB. The NDEF payload is built from the Spoolman filament metadata; incomplete fields (missing `color_name`, `nozzle_temp_min`, etc.) produce per-field warnings surfaced as a UI toast. After a successful NFC write the tag UID is persisted back to Spoolman's `extra.tag` via a safe key-merge that preserves all other custom extra fields. +- **Spoolman-aware scale weight sync** — The SpoolBuddy scale endpoint reads `filament.spool_weight` from Spoolman to compute the tare, falling back to 250 g when unset. Weight is written back to Spoolman's `remaining_weight`. + +### Fixed +- **Tag-clear preserves other Spoolman extra keys** — Clearing a tag UID from the inventory PATCH endpoint previously sent `extra: {}`, destroying any custom Spoolman extra fields. The endpoint now fetches the current extra dict, drops only the `tag` key, and PATCHes the remainder. +- **Malformed Spoolman spool no longer 500s the entire inventory list** — A spool with a missing or non-positive `id` field caused `_map_spoolman_spool` to raise `ValueError`, crashing `GET /spoolman/inventory/spools` with HTTP 500. The list endpoint now logs-and-skips individual bad rows so the rest of the list is returned normally. +- **delete / archive / restore correctly return 404 on missing spool** — Previously these endpoints returned HTTP 500 when Spoolman responded with 404. The service layer now raises `SpoolmanNotFoundError` on 404 and `SpoolmanUnavailableError` on other failures; routes map them to 404 and 503 respectively. +- **SSRF guard applied to all SpoolBuddy Spoolman paths** — `_get_spoolman_client_or_none` now runs `assert_safe_spoolman_url` before initialising the client; unsafe URLs are silently ignored with a warning log so devices continue operating. + +## [0.2.3.1] - 2026-04-20 ### Fixed - **Skip Objects: Enlarged Preview Image Fails to Load on Auth-Enabled Instances** ([#1046](https://github.com/maziggy/bambuddy/issues/1046)) — Clicking the mini print-preview thumbnail inside the Skip Objects modal opened a lightbox that showed a broken-image icon instead of the full-size plate preview. The thumbnail `` wrapped its `src` with `withStreamToken()` (which appends the short-lived camera-stream token to `/api/v1/` URLs that `` tags can't attach an `Authorization` header to), but the enlarged lightbox `` used a bare `${status.cover_url}?view=top` so the browser's unauthenticated request was rejected by the backend. Both images now go through `withStreamToken()`. Thanks to @elit3ge for the report and screenshot. diff --git a/backend/app/api/routes/_spoolman_helpers.py b/backend/app/api/routes/_spoolman_helpers.py new file mode 100644 index 000000000..f71a84a09 --- /dev/null +++ b/backend/app/api/routes/_spoolman_helpers.py @@ -0,0 +1,222 @@ +"""Pure helper functions for Spoolman spool mapping. + +No heavy dependencies — importable in unit tests without the full backend stack. +""" + +from __future__ import annotations + +import ipaddress +import logging +import math +import re +from datetime import datetime, timezone +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + + +def assert_safe_spoolman_url(url: str) -> None: + """Raise ValueError if *url* should be blocked as an SSRF risk. + + Checks performed: + - Scheme must be http or https. + - Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex + (e.g. ``0x7f000001``) are rejected — Python's ``ipaddress`` module raises + ``ValueError`` for these forms so they would otherwise bypass the IP-range + guard, but libc resolves them as valid IPv4 addresses. + - Bare numeric IP hosts in loopback (127.x, ::1), link-local (169.254.x, + fe80::), private (RFC-1918), multicast (224.x, ff::/8), or unspecified + (0.0.0.0, ::) ranges are rejected. + - IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped to their IPv4 + equivalent and subject to the same checks. + + Hostname-based addresses ("localhost", "internal.corp") require DNS resolution + and are outside the scope of this guard — they are mitigated by network-level + controls in the deployment environment. "localhost" is intentionally *not* + blocked here because running Spoolman on the same host is a common and + supported topology. + """ + parsed = urlparse(url) + if parsed.scheme.lower() not in ("http", "https"): + raise ValueError("Spoolman URL must use http or https") + + hostname = (parsed.hostname or "").lower() + + # Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or + # http://0x7f000001/). Python's ipaddress.ip_address() raises ValueError for + # these forms so they slip past the except-clause below, but the C library + # (and browsers) parse them as valid IPv4 addresses. + if re.match(r"^(0x[0-9a-f]+|[0-9]+)$", hostname, re.I): + raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation") + + try: + addr = ipaddress.ip_address(hostname) + except ValueError: + # Not a bare IP — hostname-based addresses are out of scope. + return + + # Unwrap IPv4-mapped IPv6 (::ffff:169.254.x.x etc.) so their IPv4 + # properties are evaluated correctly. + effective: ipaddress.IPv4Address | ipaddress.IPv6Address = addr + if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None: + effective = addr.ipv4_mapped + + if ( + effective.is_loopback + or effective.is_link_local + or effective.is_private + or effective.is_multicast + or effective.is_unspecified + ): + raise ValueError( + "Spoolman URL must not point to a private, loopback, link-local, multicast, or unspecified address" + ) + + +_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$") +_TAG_HEX_RE = re.compile(r"^[0-9A-F]+$") + + +def _safe_int(value: object, fallback: int) -> int: + """Convert value to int, returning fallback for None/NaN/Inf/non-numeric.""" + try: + f = float(value) # type: ignore[arg-type] + if math.isfinite(f): + return int(f) + except (TypeError, ValueError): + pass + return fallback + + +def _safe_float(value: object, fallback: float) -> float: + """Convert value to float, returning fallback for None/NaN/Inf/non-numeric.""" + try: + f = float(value) # type: ignore[arg-type] + if math.isfinite(f): + return f + except (TypeError, ValueError): + pass + return fallback + + +def _safe_optional_float(value: object) -> float | None: + """Convert value to finite float, or None if missing/NaN/Infinite/non-numeric. + + Used for optional monetary fields (price) to prevent Infinity/NaN from + reaching JSON serialisation, which raises ValueError with allow_nan=False. + """ + if value is None: + return None + try: + f = float(value) # type: ignore[arg-type] + if math.isfinite(f): + return f + except (TypeError, ValueError): + pass + return None + + +def _map_spoolman_spool(spool: dict) -> dict: + """Convert a raw Spoolman spool dict to the InventorySpool-compatible format. + + Fields not supported by Spoolman (k_profiles, slicer_filament, …) are + returned as None / empty so the frontend can still render them without + errors. The ``data_origin`` field is set to ``"spoolman"`` so UI code can + distinguish these spools from local ones. + """ + raw_id = spool.get("id") + if raw_id is None: + raise ValueError("Spoolman spool is missing required 'id' field") + try: + spool_id: int = int(raw_id) + except (TypeError, ValueError): + raise ValueError(f"Spoolman spool 'id' is not a valid integer: {raw_id!r}") + if spool_id <= 0: + raise ValueError(f"Spoolman spool 'id' must be a positive integer, got {spool_id}") + + filament: dict = spool.get("filament") or {} + if not filament: + logger.warning( + "Spoolman spool %s has no filament data — all filament fields will use defaults", + spool_id, + ) + vendor: dict = filament.get("vendor") or {} + extra: dict = spool.get("extra") or {} + + # RFID tag stored as JSON-encoded string in Spoolman extra.tag. + # 32-char hex → Bambu Lab tray UUID; 8–30-char hex → NFC tag UID. + # Accepting the full realistic UID range (4-byte = 8 chars, 7-byte = 14 chars, + # 10-byte = 20 chars) avoids silently dropping valid SpoolBuddy-written tags. + raw_tag: str = (extra.get("tag") or "").strip('"').upper() + _raw_is_hex = bool(_TAG_HEX_RE.match(raw_tag)) + tag_uid = raw_tag if _raw_is_hex and 8 <= len(raw_tag) <= 30 else None + tray_uuid = raw_tag if _raw_is_hex and len(raw_tag) == 32 else None + + # Subtype = filament name with material prefix stripped + material: str = (filament.get("material") or "").strip() + filament_name: str = (filament.get("name") or "").strip() + if material and filament_name.upper().startswith(material.upper()): + subtype: str | None = filament_name[len(material) :].strip() or None + else: + subtype = filament_name or None + + # Colour: validate as 6-char hex; fall back to neutral grey for invalid values + raw_color = (filament.get("color_hex") or "").upper().removeprefix("#") + color_hex: str = raw_color if _COLOR_HEX_RE.match(raw_color) else "808080" + rgba: str = color_hex + "FF" + + label_weight: int = _safe_int(filament.get("weight"), 1000) + used_weight: float = _safe_float(spool.get("used_weight"), 0.0) + + # Archived state – Spoolman uses a boolean ``archived`` field + archived: bool = spool.get("archived", False) + archived_at: str | None = None + if archived: + archived_at = spool.get("last_used") or spool.get("registered") + if not archived_at: + archived_at = datetime.now(timezone.utc).isoformat() + + created_at: str = spool.get("registered") or datetime.now(timezone.utc).isoformat() + + color_name: str | None = filament.get("color_name") or None + + nozzle_temp_raw = filament.get("settings_extruder_temp") + nozzle_temp_min: int | None = _safe_int(nozzle_temp_raw, 0) or None + + return { + "id": spool_id, + "material": material, + "subtype": subtype, + "color_name": color_name, + "rgba": rgba, + "brand": vendor.get("name") or None, + "label_weight": label_weight, + "core_weight": _safe_int(filament.get("spool_weight"), 250), + "core_weight_catalog_id": None, + "weight_used": used_weight, + "weight_locked": False, + "last_scale_weight": None, + "last_weighed_at": None, + # slicer_filament_name carries the Spoolman filament name for display + "slicer_filament": None, + "slicer_filament_name": filament_name or None, + "nozzle_temp_min": nozzle_temp_min, + "nozzle_temp_max": None, + "note": spool.get("comment") or None, + "added_full": None, + "last_used": spool.get("last_used"), + # encode_time semantics differ: local records NFC write time; Spoolman first_used + # records first print use — different events; using first_used as best available proxy. + "encode_time": spool.get("first_used"), + "tag_uid": tag_uid, + "tray_uuid": tray_uuid, + "data_origin": "spoolman", + "tag_type": "spoolman", + "archived_at": archived_at, + "created_at": created_at, + # Spoolman has no updated_at field; use registered timestamp as best available proxy + "updated_at": created_at, + "cost_per_kg": _safe_optional_float(spool.get("price")), + "storage_location": spool.get("location") or None, + "k_profiles": [], + } diff --git a/backend/app/api/routes/spoolbuddy.py b/backend/app/api/routes/spoolbuddy.py index 58aeed615..1242e875a 100644 --- a/backend/app/api/routes/spoolbuddy.py +++ b/backend/app/api/routes/spoolbuddy.py @@ -7,6 +7,7 @@ import time from datetime import datetime, timedelta, timezone from urllib.parse import urlparse +import httpx from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -34,10 +35,12 @@ from backend.app.schemas.spoolbuddy import ( TagRemovedRequest, TagScannedRequest, UpdateSpoolWeightRequest, + UpdateStatusRequest, WriteTagRequest, WriteTagResultRequest, ) from backend.app.services.spool_tag_matcher import get_spool_by_tag +from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError logger = logging.getLogger(__name__) @@ -49,6 +52,45 @@ _spoolbuddy_online_last_broadcast: dict[str, float] = {} _diagnostic_results: dict[tuple[str, str], dict] = {} +async def _get_spoolman_client_or_none(db: AsyncSession): + """Return a SpoolmanClient if Spoolman is enabled with a safe URL, else None.""" + from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url + from backend.app.models.settings import Settings + from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client + + settings_result = await db.execute(select(Settings)) + settings_dict = {s.key: s.value for s in settings_result.scalars().all()} + spoolman_url = settings_dict.get("spoolman_url", "").strip() + spoolman_enabled = settings_dict.get("spoolman_enabled", "false").lower() == "true" and bool(spoolman_url) + + if not spoolman_enabled: + return None + + # SSRF guard: reject dangerous schemes and private/loopback/link-local/multicast IPs. + try: + assert_safe_spoolman_url(spoolman_url) + except ValueError as exc: + logger.warning( + "Spoolman integration disabled: URL %r rejected by SSRF guard: %s", + spoolman_url, + exc, + ) + return None + + client = await get_spoolman_client() + if not client or client.base_url != spoolman_url.rstrip("/"): + try: + client = await init_spoolman_client(spoolman_url) + except ValueError as exc: + logger.warning( + "Spoolman integration disabled: URL %r rejected on re-initialisation: %s", + spoolman_url, + exc, + ) + return None + return client + + def _is_online(device: SpoolBuddyDevice) -> bool: if not device.last_seen: return False @@ -171,8 +213,8 @@ async def register_device( from backend.app.services.spoolbuddy_ssh import get_public_key response.ssh_public_key = await get_public_key() - except Exception: - pass # Key not generated yet — daemon can still work without it + except Exception as exc: + logger.warning("Could not attach SSH public key to heartbeat response: %s", exc) return response @@ -321,28 +363,89 @@ async def nfc_tag_scanned( }, } ) - logger.info("SpoolBuddy tag matched: %s -> spool %d", req.tag_uid, spool.id) - else: - await ws_manager.broadcast( - { - "type": "spoolbuddy_unknown_tag", - "device_id": req.device_id, - "tag_uid": req.tag_uid, - "sak": req.sak, - "tag_type": req.tag_type, - } - ) - logger.info( - "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s", - req.tag_uid, - len(req.tag_uid or ""), - req.tray_uuid, - len(req.tray_uuid or ""), - req.tag_type, - req.sak, - ) + logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id) + return {"status": "ok", "matched": True, "spool_id": spool.id} - return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None} + # Local DB miss — fall back to Spoolman when enabled + from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool + + client = await _get_spoolman_client_or_none(db) + if client is not None: + try: + cached_spools = await client.get_spools() + sm_spool: dict | None = None + if req.tray_uuid: + sm_spool = await client.find_spool_by_tag(req.tray_uuid, cached_spools=cached_spools) + if sm_spool is None and req.tag_uid: + sm_spool = await client.find_spool_by_tag(req.tag_uid, cached_spools=cached_spools) + + if sm_spool is not None: + mapped = _map_spoolman_spool(sm_spool) + await ws_manager.broadcast( + { + "type": "spoolbuddy_tag_matched", + "device_id": req.device_id, + "tag_uid": req.tag_uid, + "spool": { + "id": mapped["id"], + "material": mapped["material"], + "subtype": mapped["subtype"], + "color_name": mapped["color_name"], + "rgba": mapped["rgba"], + "brand": mapped["brand"], + "label_weight": mapped["label_weight"], + "core_weight": mapped["core_weight"], + "weight_used": mapped["weight_used"], + }, + } + ) + logger.info("SpoolBuddy tag matched (Spoolman): %s -> spool %d", req.tag_uid, mapped["id"]) + return {"status": "ok", "matched": True, "spool_id": mapped["id"]} + except ValueError as exc: + logger.error( + "Spoolman returned malformed spool data during tag lookup for %s: %s", + req.tag_uid, + exc, + ) + return {"status": "ok", "matched": False, "spool_id": None} + except (httpx.RequestError, httpx.HTTPStatusError, SpoolmanUnavailableError): + logger.warning( + "Spoolman unreachable during tag lookup for %s", + req.tag_uid, + ) + # Degrade gracefully on any Spoolman connectivity failure — device must not receive 500. + # Also suppresses unknown_tag broadcast: the UI cannot distinguish a Spoolman outage + # from "spool not registered", which would trigger duplicate-registration flows. + return {"status": "ok", "matched": False, "spool_id": None} + except Exception as exc: + logger.error( + "Spoolman tag lookup failed unexpectedly for %s: %s", + req.tag_uid, + exc, + ) + # Same silent-return policy: an unexpected error must not break device operation + # or trigger spurious duplicate-registration flows in the UI. + return {"status": "ok", "matched": False, "spool_id": None} + + await ws_manager.broadcast( + { + "type": "spoolbuddy_unknown_tag", + "device_id": req.device_id, + "tag_uid": req.tag_uid, + "sak": req.sak, + "tag_type": req.tag_type, + } + ) + logger.info( + "SpoolBuddy unknown tag: uid=%s (len=%d), tray_uuid=%s (len=%d), type=%s, sak=%s", + req.tag_uid, + len(req.tag_uid or ""), + req.tray_uuid, + len(req.tray_uuid or ""), + req.tag_type, + req.sak, + ) + return {"status": "ok", "matched": False, "spool_id": None} @router.post("/nfc/tag-removed") @@ -368,38 +471,95 @@ async def nfc_write_tag( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ): """Queue an NFC tag write command for a SpoolBuddy device.""" - import json - from backend.app.models.spool import Spool - from backend.app.services.opentag3d import encode_opentag3d + from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped - # Find the spool - result = await db.execute(select(Spool).where(Spool.id == req.spool_id)) - spool = result.scalar_one_or_none() - if not spool: - raise HTTPException(status_code=404, detail="Spool not found") - - # Find the device + # Find the device first (required regardless of spool source) result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id)) device = result.scalar_one_or_none() if not device: raise HTTPException(status_code=404, detail="Device not registered") - # Encode OpenTag3D NDEF data - ndef_data = encode_opentag3d(spool) + # Try local DB first + result = await db.execute(select(Spool).where(Spool.id == req.spool_id)) + spool = result.scalar_one_or_none() + + nfc_warnings: list[str] = [] + if spool: + ndef_data = encode_opentag3d(spool) + data_origin = "local" + else: + # Local DB miss — fall back to Spoolman when enabled + from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool + + sm_client = await _get_spoolman_client_or_none(db) + if sm_client is None: + raise HTTPException(status_code=404, detail="Spool not found") + + try: + sm_spool = await sm_client.get_spool(req.spool_id) + except SpoolmanNotFoundError: + raise HTTPException(status_code=404, detail="Spool not found") + except SpoolmanUnavailableError: + raise HTTPException(status_code=503, detail="Spoolman server is not reachable") + + try: + mapped = _map_spoolman_spool(sm_spool) + except ValueError as exc: + logger.warning("Spoolman returned invalid spool for write-tag: %s", exc) + raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") + + if not mapped.get("material"): + raise HTTPException( + status_code=400, + detail="Spoolman spool has no material set — cannot encode NFC tag", + ) + + ndef_data = encode_opentag3d_from_mapped(mapped) + data_origin = "spoolman" + + # Warn when fields that drive NFC content are absent in Spoolman. + if not mapped.get("color_name"): + nfc_warnings.append("color_name not set in Spoolman — tag encodes empty color name") + if not mapped.get("nozzle_temp_min"): + nfc_warnings.append("nozzle_temp_min not set in Spoolman — tag encodes 0 °C") + if not mapped.get("subtype"): + nfc_warnings.append("subtype not set in Spoolman — tag encodes empty subtype") + if not mapped.get("brand"): + nfc_warnings.append("brand/vendor not set in Spoolman — tag encodes empty brand") + if not mapped.get("rgba"): + nfc_warnings.append("rgba not set in Spoolman — tag encodes default colour") + if not mapped.get("label_weight"): + nfc_warnings.append("label_weight not set in Spoolman — tag encodes 0 g") + if nfc_warnings: + logger.warning( + "NFC encode for Spoolman spool %d has incomplete data: %s", + req.spool_id, + "; ".join(nfc_warnings), + ) # Store write payload and set pending command device.pending_write_payload = json.dumps( { - "spool_id": spool.id, + "spool_id": req.spool_id, "ndef_data_hex": ndef_data.hex(), + "data_origin": data_origin, } ) device.pending_command = "write_tag" await db.commit() - logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data)) - return {"status": "queued"} + logger.info( + "Write tag queued for device %s, spool %d (%s, %d bytes)", + req.device_id, + req.spool_id, + data_origin, + len(ndef_data), + ) + result: dict = {"status": "queued"} + if nfc_warnings: + result["warnings"] = nfc_warnings + return result @router.post("/nfc/write-result") @@ -409,37 +569,137 @@ async def nfc_write_result( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ): """Handle NFC tag write result from SpoolBuddy daemon.""" - # Find the device and clear pending state + # Find the device result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == req.device_id)) device = result.scalar_one_or_none() if not device: raise HTTPException(status_code=404, detail="Device not registered") + # Capture data_origin before clearing the payload + try: + payload_dict = json.loads(device.pending_write_payload or "{}") + except (json.JSONDecodeError, TypeError): + payload_dict = {} + logger.warning("Malformed pending_write_payload for device %s — treating as local", req.device_id) + data_origin = payload_dict.get("data_origin", "local") + device.pending_command = None device.pending_write_payload = None if req.success: - # Link the tag to the spool - from backend.app.models.spool import Spool + if data_origin == "spoolman": + # Update Spoolman extra.tag with the written NFC UID using a safe merge + # (fetches current extra first to avoid overwriting other custom fields). + sm_client = await _get_spoolman_client_or_none(db) + if sm_client is None: + logger.warning("Spoolman not configured; cannot persist tag link for spool %d", req.spool_id) + await db.commit() + await ws_manager.broadcast( + { + "type": "spoolbuddy_tag_link_failed", + "device_id": req.device_id, + "spool_id": req.spool_id, + "tag_uid": req.tag_uid, + "message": "Spoolman not configured", + } + ) + raise HTTPException( + status_code=502, + detail="Tag written to NFC but Spoolman is not configured; link not persisted", + ) + + _tag_link_ok = False + try: + tag_value = json.dumps(req.tag_uid.upper()) + await sm_client.merge_spool_extra(req.spool_id, {"tag": tag_value}) + logger.info( + "Spoolman tag written and linked: spool %d -> tag %s", + req.spool_id, + req.tag_uid, + ) + _tag_link_ok = True + except SpoolmanNotFoundError: + logger.error( + "Spoolman spool %d deleted before tag write-back could be persisted", + req.spool_id, + ) + # fall through to broadcast + raise 502 below + except SpoolmanUnavailableError: + logger.error( + "Spoolman unreachable during tag write-back for spool %d", + req.spool_id, + ) + # fall through to broadcast + raise 502 below + except Exception: + logger.exception( + "Unexpected error during Spoolman tag write-back for spool %d", + req.spool_id, + ) + # fall through to broadcast + raise 502 below + + await db.commit() + if _tag_link_ok: + await ws_manager.broadcast( + { + "type": "spoolbuddy_tag_written", + "device_id": req.device_id, + "spool_id": req.spool_id, + "tag_uid": req.tag_uid, + } + ) + else: + await ws_manager.broadcast( + { + "type": "spoolbuddy_tag_link_failed", + "device_id": req.device_id, + "spool_id": req.spool_id, + "tag_uid": req.tag_uid, + # Generic message — full exception (may contain internal URLs/hostnames) + # is logged server-side only to prevent information leakage via WebSocket. + "message": "Spoolman link failed", + } + ) + raise HTTPException( + status_code=502, + detail="Tag written to NFC but Spoolman link failed", + ) + else: + # Link the tag to the local DB spool + from backend.app.models.spool import Spool + + result = await db.execute(select(Spool).where(Spool.id == req.spool_id)) + spool = result.scalar_one_or_none() + if spool is None: + logger.warning( + "NFC tag written for spool %d but it no longer exists in local DB; tag is orphaned", + req.spool_id, + ) + await db.commit() + await ws_manager.broadcast( + { + "type": "spoolbuddy_tag_link_failed", + "device_id": req.device_id, + "spool_id": req.spool_id, + "message": "Spool not found", + } + ) + return {"status": "ok", "linked": False, "message": "Spool not found"} - result = await db.execute(select(Spool).where(Spool.id == req.spool_id)) - spool = result.scalar_one_or_none() - if spool: spool.tag_uid = req.tag_uid.upper() spool.tag_type = "ntag" spool.data_origin = "opentag3d" spool.encode_time = datetime.now(timezone.utc) logger.info("Tag written and linked: spool %d -> tag %s", spool.id, req.tag_uid) - await db.commit() - await ws_manager.broadcast( - { - "type": "spoolbuddy_tag_written", - "device_id": req.device_id, - "spool_id": req.spool_id, - "tag_uid": req.tag_uid, - } - ) + await db.commit() + await ws_manager.broadcast( + { + "type": "spoolbuddy_tag_written", + "device_id": req.device_id, + "spool_id": req.spool_id, + "tag_uid": req.tag_uid, + } + ) else: await db.commit() await ws_manager.broadcast( @@ -504,10 +764,47 @@ async def update_spool_weight( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ): """Update spool's used weight from scale reading.""" + from backend.app.api.routes._spoolman_helpers import _safe_float + + sm_client = await _get_spoolman_client_or_none(db) + if sm_client is not None: + try: + sm_spool = await sm_client.get_spool(req.spool_id) + except SpoolmanNotFoundError: + raise HTTPException(status_code=404, detail="Spool not found in Spoolman") + except SpoolmanUnavailableError: + raise HTTPException(status_code=503, detail="Spoolman server is not reachable") + + filament = sm_spool.get("filament") or {} + raw_spool_weight = filament.get("spool_weight") + if not raw_spool_weight: + logger.warning( + "Spoolman spool %d has no spool_weight set; using 250g fallback for tare", + req.spool_id, + ) + core_weight = _safe_float(raw_spool_weight, 250.0) + label_weight = _safe_float(filament.get("weight"), 1000.0) + remaining_weight = max(0.0, req.weight_grams - core_weight) + + result = await sm_client.update_spool(spool_id=req.spool_id, remaining_weight=remaining_weight) + if result is None: + raise HTTPException(status_code=502, detail="Failed to update spool weight in Spoolman") + + weight_used = max(0.0, label_weight - remaining_weight) + logger.info( + "SpoolBuddy updated Spoolman spool %d: %.1fg on scale, core=%.1fg → %.1fg remaining", + req.spool_id, + req.weight_grams, + core_weight, + remaining_weight, + ) + return {"status": "ok", "weight_used": weight_used} + + # Local DB mode from backend.app.models.spool import Spool - result = await db.execute(select(Spool).where(Spool.id == req.spool_id)) - spool = result.scalar_one_or_none() + db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id)) + spool = db_result.scalar_one_or_none() if not spool: raise HTTPException(status_code=404, detail="Spool not found") @@ -959,13 +1256,14 @@ async def get_ssh_public_key( key = await get_public_key() return {"public_key": key} except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e + logger.error("Failed to get SSH public key: %s", e) + raise HTTPException(status_code=500, detail="Failed to retrieve SSH public key") from e @router.post("/devices/{device_id}/update-status") async def report_update_status( device_id: str, - req: dict, + req: UpdateStatusRequest, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ): @@ -975,25 +1273,24 @@ async def report_update_status( if not device: raise HTTPException(status_code=404, detail="Device not registered") - status = req.get("status", "") - message = req.get("message", "") + device.update_status = req.status + device.update_message = req.message + # Only "complete" clears pending_command here. "error" leaves it set so the user can retry + # via the UI. The SSH service's own _update_progress clears on both "complete" and "error" + # because it owns the full update lifecycle end-to-end. + if req.status == "complete": + device.pending_command = None + await db.commit() - if status in ("updating", "complete", "error"): - device.update_status = status - device.update_message = message[:255] if message else None - if status == "complete": - device.pending_command = None - await db.commit() - - logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, status, message) - await ws_manager.broadcast( - { - "type": "spoolbuddy_update", - "device_id": device_id, - "update_status": status, - "update_message": message, - } - ) + logger.info("SpoolBuddy %s: update status=%s msg=%s", device_id, req.status, req.message) + await ws_manager.broadcast( + { + "type": "spoolbuddy_update", + "device_id": device_id, + "update_status": req.status, + "update_message": req.message, + } + ) return {"status": "ok"} diff --git a/backend/app/api/routes/spoolman_inventory.py b/backend/app/api/routes/spoolman_inventory.py new file mode 100644 index 000000000..75b38044c --- /dev/null +++ b/backend/app/api/routes/spoolman_inventory.py @@ -0,0 +1,531 @@ +"""Spoolman inventory proxy endpoints. + +Translates between Spoolman's data model and Bambuddy's internal +InventorySpool format so the frontend can use a single unified inventory UI +regardless of whether data comes from the local database or Spoolman. +""" + +from __future__ import annotations + +import logging +import re +import time +from contextlib import asynccontextmanager + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, field_validator, model_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.api.routes._spoolman_helpers import ( + _map_spoolman_spool, + _safe_float, + _safe_int, + assert_safe_spoolman_url, +) +from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.database import get_db +from backend.app.core.permissions import Permission +from backend.app.models.settings import Settings +from backend.app.models.user import User +from backend.app.services.spoolman import ( + SpoolmanClient, + SpoolmanClientError, + SpoolmanNotFoundError, + SpoolmanUnavailableError, + get_spoolman_client, + init_spoolman_client, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/spoolman/inventory", tags=["spoolman-inventory"]) + + +# Cache the last successful health-check timestamp to avoid a round-trip on +# every request. A failed check clears the cache immediately. +_health_check_cache: dict[str, float] = {} +_HEALTH_CHECK_TTL = 30.0 # seconds + + +def _tag_cleared(val: str | None) -> bool: + """Return True when a PATCH field explicitly removes a tag (null or empty string).""" + return val is None or val == "" + + +async def _get_client(db: AsyncSession) -> SpoolmanClient: + """Return an authenticated Spoolman client or raise an HTTP error.""" + result = await db.execute(select(Settings)) + settings: dict[str, str] = {s.key: s.value for s in result.scalars().all()} + + enabled = settings.get("spoolman_enabled", "false").lower() == "true" + url = settings.get("spoolman_url", "").strip() + + if not enabled: + raise HTTPException(status_code=400, detail="Spoolman integration is not enabled") + if not url: + raise HTTPException(status_code=400, detail="Spoolman URL is not configured") + + # SSRF guard: reject dangerous schemes and bare private/loopback/link-local/multicast IPs. + # Raises ValueError with a descriptive message on any violation. + try: + assert_safe_spoolman_url(url) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # Re-use the cached client when URL is unchanged; reinitialise on URL change (cache invalidation). + client = await get_spoolman_client() + if not client or client.base_url != url.rstrip("/"): + try: + client = await init_spoolman_client(url) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # Only call health_check() when the cached result has expired. + # Evict stale entries when URL changes (only one Spoolman URL is active at a time). + if url not in _health_check_cache and _health_check_cache: + _health_check_cache.clear() + now = time.monotonic() + last_ok = _health_check_cache.get(url, 0.0) + if now - last_ok > _HEALTH_CHECK_TTL: + if not await client.health_check(): + _health_check_cache.pop(url, None) + raise HTTPException(status_code=503, detail="Spoolman server is not reachable") + _health_check_cache[url] = now + + return client + + +@asynccontextmanager +async def _translate_spoolman_errors(): + """Translate Spoolman exceptions to HTTP responses for all inventory endpoints. + + Maps SpoolmanNotFoundError → 404 and SpoolmanUnavailableError → 503. + Add new SpoolmanClient exception mappings here rather than in individual handlers. + """ + try: + yield + except SpoolmanNotFoundError as exc: + raise HTTPException(status_code=404, detail="Spool not found in Spoolman") from exc + except SpoolmanClientError as exc: + raise HTTPException(status_code=exc.status_code, detail="Spoolman rejected the request") from exc + except SpoolmanUnavailableError as exc: + raise HTTPException(status_code=503, detail="Spoolman server is not reachable") from exc + + +async def _apply_price_if_set(client: SpoolmanClient, spool: dict, cost_per_kg: float | None) -> dict: + """Patch the spool's price via a follow-up update when cost_per_kg is provided. + + Bambuddy's SpoolmanClient.create_spool() does not forward price to Spoolman's POST /spool + endpoint, so a follow-up PATCH via update_spool_full is needed to set it. + On failure, logs an error and returns the original spool (caller gets HTTP 200 without price). + """ + if cost_per_kg is None: + return spool + try: + async with _translate_spoolman_errors(): + return await client.update_spool_full(spool["id"], price=cost_per_kg) + except HTTPException: + logger.error( + "Price update failed for spool %d; spool created without price (cost_per_kg=%s)", + spool["id"], + cost_per_kg, + ) + return spool + + +# --------------------------------------------------------------------------- +# Request / response schemas +# --------------------------------------------------------------------------- + + +_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$") + + +def _validate_rgba(v: str | None) -> str | None: + if v is None: + return v + clean = v.removeprefix("#") + if not _HEX_RE.match(clean): + raise ValueError("rgba must be a 6 or 8 character hex string (RRGGBB or RRGGBBAA)") + return clean.upper() + + +def _validate_storage_location(v: str | None) -> str | None: + if v is not None and any(c in v for c in ("\r", "\n", "\x00")): + raise ValueError("storage_location must not contain control characters") + return v + + +class SpoolmanInventoryCreate(BaseModel): + material: str = Field(..., min_length=1, max_length=64) + subtype: str | None = Field(None, max_length=64) + brand: str | None = Field(None, max_length=128) + color_name: str | None = Field(None, max_length=64) + rgba: str | None = Field(None, max_length=8) + label_weight: int = Field(1000, ge=1, le=100_000) + core_weight: int = Field( + 250, ge=0, le=10_000 + ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool) + weight_used: float = Field(0.0, ge=0.0, le=100_000.0) + note: str | None = Field(None, max_length=1000) + cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0) + storage_location: str | None = Field(None, max_length=255) + + @field_validator("rgba") + @classmethod + def validate_rgba(cls, v: str | None) -> str | None: + return _validate_rgba(v) + + @field_validator("storage_location") + @classmethod + def validate_storage_location(cls, v: str | None) -> str | None: + return _validate_storage_location(v) + + @model_validator(mode="after") + def validate_weight_consistency(self) -> SpoolmanInventoryCreate: + if self.weight_used > self.label_weight: + raise ValueError("weight_used must not exceed label_weight") + return self + + +class SpoolmanInventoryUpdate(BaseModel): + material: str | None = Field(None, min_length=1, max_length=64) + subtype: str | None = Field(None, max_length=64) + brand: str | None = Field(None, max_length=128) + color_name: str | None = Field(None, max_length=64) + rgba: str | None = Field(None, max_length=8) + label_weight: int | None = Field(None, ge=1, le=100_000) + core_weight: int | None = Field( + None, ge=0, le=10_000 + ) # Accepted for schema parity but not persisted to Spoolman (stored on filament type, not spool) + weight_used: float | None = Field(None, ge=0.0, le=100_000.0) + note: str | None = Field(None, max_length=1000) + cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0) + tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$") + tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$") + storage_location: str | None = Field(None, max_length=255) + + @field_validator("rgba") + @classmethod + def validate_rgba(cls, v: str | None) -> str | None: + return _validate_rgba(v) + + @field_validator("storage_location") + @classmethod + def validate_storage_location(cls, v: str | None) -> str | None: + return _validate_storage_location(v) + + @model_validator(mode="after") + def validate_weight_consistency(self) -> SpoolmanInventoryUpdate: + if self.weight_used is not None and self.label_weight is not None: + if self.weight_used > self.label_weight: + raise ValueError("weight_used must not exceed label_weight") + return self + + +class SpoolmanInventoryBulkCreate(BaseModel): + spool: SpoolmanInventoryCreate + quantity: int = Field(1, ge=1, le=50) + + +class SpoolWeightUpdate(BaseModel): + weight_grams: float = Field(..., ge=0.0, le=100_000.0) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.get("/spools") +async def list_spools( + include_archived: bool = Query(False), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +) -> list[dict]: + """Return all Spoolman spools in the InventorySpool format.""" + client = await _get_client(db) + async with _translate_spoolman_errors(): + spools = await client.get_all_spools(allow_archived=include_archived) + result = [] + for s in spools: + try: + result.append(_map_spoolman_spool(s)) + except ValueError as exc: + logger.warning("Skipping malformed Spoolman spool (id=%r): %s", s.get("id"), exc) + return result + + +@router.get("/spools/{spool_id}") +async def get_spool( + spool_id: int = Path(..., gt=0), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ), +) -> dict: + """Return a single Spoolman spool in the InventorySpool format.""" + client = await _get_client(db) + async with _translate_spoolman_errors(): + spool = await client.get_spool(spool_id) + try: + return _map_spoolman_spool(spool) + except ValueError as exc: + logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc) + raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc + + +@router.post("/spools") +async def create_spool( + data: SpoolmanInventoryCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> dict: + """Create a new spool in Spoolman, auto-creating vendor and filament as needed.""" + client = await _get_client(db) + + color_hex = (data.rgba or "808080FF")[:6] + async with _translate_spoolman_errors(): + filament_id = await client.find_or_create_filament( + material=data.material, + subtype=data.subtype or "", + brand=data.brand, + color_hex=color_hex, + label_weight=data.label_weight, + color_name=data.color_name, + ) + if not filament_id: + raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman") + + remaining = max(0.0, data.label_weight - data.weight_used) + spool = await client.create_spool( + filament_id=filament_id, + remaining_weight=remaining, + comment=data.note or None, + location=data.storage_location or None, + ) + if not spool: + raise HTTPException(status_code=500, detail="Failed to create spool in Spoolman") + + spool = await _apply_price_if_set(client, spool, data.cost_per_kg) + return _map_spoolman_spool(spool) + + +@router.post("/spools/bulk") +async def bulk_create_spools( + payload: SpoolmanInventoryBulkCreate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> Response: + """Create multiple identical spools in Spoolman.""" + client = await _get_client(db) + data = payload.spool + + color_hex = (data.rgba or "808080FF")[:6] + async with _translate_spoolman_errors(): + filament_id = await client.find_or_create_filament( + material=data.material, + subtype=data.subtype or "", + brand=data.brand, + color_hex=color_hex, + label_weight=data.label_weight, + ) + if not filament_id: + raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman") + + remaining = max(0.0, data.label_weight - data.weight_used) + created: list[dict] = [] + for _ in range(payload.quantity): + spool = await client.create_spool( + filament_id=filament_id, + remaining_weight=remaining, + comment=data.note or None, + location=data.storage_location or None, + ) + if spool: + spool = await _apply_price_if_set(client, spool, data.cost_per_kg) + created.append(_map_spoolman_spool(spool)) + + if not created: + raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman") + + if len(created) < payload.quantity: + # Some spool creations failed — return 207 Multi-Status so the caller + # can distinguish a full success from a partial one and show a useful message. + return JSONResponse( + status_code=207, + content={ + "created": created, + "requested_count": payload.quantity, + "failed_count": payload.quantity - len(created), + }, + ) + + return JSONResponse(status_code=200, content=created) + + +@router.patch("/spools/{spool_id}") +async def update_spool( + *, + spool_id: int = Path(..., gt=0), + data: SpoolmanInventoryUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> dict: + """Update an existing Spoolman spool, re-linking the filament if metadata changed.""" + client = await _get_client(db) + + async with _translate_spoolman_errors(): + current = await client.get_spool(spool_id) + + cur_filament: dict = current.get("filament") or {} + cur_vendor: dict = cur_filament.get("vendor") or {} + cur_mat: str = (cur_filament.get("material") or "").strip() + cur_name: str = (cur_filament.get("name") or "").strip() + if cur_mat and cur_name.upper().startswith(cur_mat.upper()): + cur_subtype: str = cur_name[len(cur_mat) :].strip() + else: + cur_subtype = cur_name + + # Resolve final values: use request value if provided, else keep current + material = data.material if data.material is not None else cur_mat + subtype = data.subtype if data.subtype is not None else cur_subtype + brand = data.brand if data.brand is not None else (cur_vendor.get("name") or None) + color_name = data.color_name if data.color_name is not None else (cur_filament.get("color_name") or None) + cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#") + rgba = data.rgba if data.rgba is not None else (cur_color + "FF") + label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000) + weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0) + note = data.note if data.note is not None else current.get("comment") + storage_location_changed = "storage_location" in data.model_fields_set + storage_location = data.storage_location if storage_location_changed else current.get("location") + + color_hex = rgba[:6] + async with _translate_spoolman_errors(): + filament_id = await client.find_or_create_filament( + material=material, + subtype=subtype or "", + brand=brand, + color_hex=color_hex, + label_weight=label_weight, + color_name=color_name, + ) + if not filament_id: + raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman") + + remaining = max(0.0, label_weight - weight_used) + + # Tag removal: clear only the "tag" key so other custom Spoolman extra fields + # set outside Bambuddy are preserved. + tag_nulled = ( + ("tag_uid" in data.model_fields_set or "tray_uuid" in data.model_fields_set) + and _tag_cleared(data.tag_uid) + and _tag_cleared(data.tray_uuid) + ) + + # Serialise tag-clear + PATCH under the per-spool extra lock to prevent a + # concurrent merge_spool_extra call (e.g. NFC write-back) from overwriting + # the tag key between our read and our write. + async with client.extra_lock(spool_id): + if tag_nulled: + # Re-fetch inside the lock so we work with fresh extra data. + async with _translate_spoolman_errors(): + fresh = await client.get_spool(spool_id) + cur_extra = dict(fresh.get("extra") or {}) + cur_extra.pop("tag", None) + extra: dict | None = cur_extra + else: + extra = None + + async with _translate_spoolman_errors(): + updated = await client.update_spool_full( + spool_id=spool_id, + filament_id=filament_id, + remaining_weight=remaining, + comment=note or "", + price=data.cost_per_kg, + extra=extra, + location=storage_location or None, + clear_location=storage_location_changed and not storage_location, + ) + + return _map_spoolman_spool(updated) + + +@router.delete("/spools/{spool_id}") +async def delete_spool( + spool_id: int = Path(..., gt=0), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> dict: + """Permanently delete a spool from Spoolman.""" + client = await _get_client(db) + async with _translate_spoolman_errors(): + await client.delete_spool(spool_id) + return {"status": "deleted"} + + +@router.post("/spools/{spool_id}/archive") +async def archive_spool( + spool_id: int = Path(..., gt=0), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> dict: + """Archive a spool in Spoolman (soft-delete).""" + client = await _get_client(db) + async with _translate_spoolman_errors(): + spool = await client.set_spool_archived(spool_id, archived=True) + try: + return _map_spoolman_spool(spool) + except ValueError as exc: + logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc) + raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc + + +@router.post("/spools/{spool_id}/restore") +async def restore_spool( + spool_id: int = Path(..., gt=0), + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> dict: + """Restore an archived spool in Spoolman.""" + client = await _get_client(db) + async with _translate_spoolman_errors(): + spool = await client.set_spool_archived(spool_id, archived=False) + try: + return _map_spoolman_spool(spool) + except ValueError as exc: + logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc) + raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc + + +@router.patch("/spools/{spool_id}/weight") +async def sync_spool_weight( + *, + spool_id: int = Path(..., gt=0), + data: SpoolWeightUpdate, + db: AsyncSession = Depends(get_db), + _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), +) -> dict: + """Update a spool's remaining weight from a measured gross weight. + + Computes remaining = gross_weight - filament.spool_weight (empty-spool + weight from Spoolman; falls back to 250 g when unset) and updates + Spoolman accordingly. + """ + client = await _get_client(db) + + async with _translate_spoolman_errors(): + current = await client.get_spool(spool_id) + + cur_filament = current.get("filament") or {} + core_weight = _safe_float(cur_filament.get("spool_weight"), 250.0) + remaining = max(0.0, data.weight_grams - core_weight) + + async with _translate_spoolman_errors(): + updated = await client.update_spool_full(spool_id=spool_id, remaining_weight=remaining) + + upd_filament = updated.get("filament") or {} + label_weight = _safe_int(upd_filament.get("weight"), 1000) + weight_used = max(0.0, label_weight - remaining) + return {"status": "ok", "weight_used": weight_used} diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 128b8f2a5..f7297e5f2 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -25,6 +25,45 @@ from backend.app.models.user import User logger = logging.getLogger(__name__) +# Permissions that cannot be accessed via API key. +# API keys are limited to device/inventory operations. The following capabilities +# are restricted to fully-authenticated users: user/group/API-key management, +# settings read/backup/restore, firmware updates, and GitHub-backed backup/restore. +_APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset( + { + Permission.SETTINGS_READ, + Permission.SETTINGS_UPDATE, + Permission.SETTINGS_BACKUP, + Permission.SETTINGS_RESTORE, + Permission.USERS_READ, + Permission.USERS_CREATE, + Permission.USERS_UPDATE, + Permission.USERS_DELETE, + Permission.GROUPS_READ, + Permission.GROUPS_CREATE, + Permission.GROUPS_UPDATE, + Permission.GROUPS_DELETE, + Permission.API_KEYS_CREATE, + Permission.API_KEYS_UPDATE, + Permission.API_KEYS_DELETE, + Permission.API_KEYS_READ, + Permission.GITHUB_BACKUP, + Permission.GITHUB_RESTORE, + Permission.FIRMWARE_UPDATE, + } +) + + +def _check_apikey_permissions(perm_strings: list[str]) -> None: + """Raise 403 if any required permission is admin-only (not accessible via API key).""" + denied = _APIKEY_DENIED_PERMISSIONS.intersection(perm_strings) + if denied: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="API keys cannot be used for administrative operations", + ) + + # Password hashing # Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues # pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations @@ -631,8 +670,7 @@ async def get_api_key( detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer '", ) - # M-NEW-2: Pre-filter by key_prefix (first 8 chars) to avoid O(n) pbkdf2 over all - # enabled keys — same fix as in _validate_api_key (L-1 from previous review). + # Pre-filter by key_prefix to avoid O(n) pbkdf2 hashes across all enabled keys. key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value result = await db.execute( select(APIKey).where( @@ -756,6 +794,7 @@ def require_permission(*permissions: str | Permission): if x_api_key: api_key = await _validate_api_key(db, x_api_key) if api_key: + _check_apikey_permissions(perm_strings) return None # API key valid, allow access credentials_exception = HTTPException( @@ -772,6 +811,7 @@ def require_permission(*permissions: str | Permission): if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: + _check_apikey_permissions(perm_strings) return None # API key valid, allow access raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -837,6 +877,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission): if x_api_key: api_key = await _validate_api_key(db, x_api_key) if api_key: + _check_apikey_permissions(perm_strings) return None # API key valid, allow access # Check for Bearer token (could be JWT or API key) @@ -846,6 +887,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission): if token.startswith("bb_"): api_key = await _validate_api_key(db, token) if api_key: + _check_apikey_permissions(perm_strings) return None # API key valid, allow access raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 5cc855907..5d158e420 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1107,6 +1107,13 @@ async def run_migrations(conn): # Migration: Add cost tracking fields to spool table await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL") + + # Migration: Add user-editable storage location to spool table + await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)") + # Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC + # UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars). + # SQLite ignores VARCHAR sizes; this is a no-op there but needed for PostgreSQL. + await _safe_execute(conn, "ALTER TABLE spool ALTER COLUMN tag_uid TYPE VARCHAR(32)") # Migration: Add cost field to spool_usage_history table await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL") # Migration: Add archive_id field to spool_usage_history table @@ -1206,6 +1213,9 @@ async def run_migrations(conn): # Migration: Add system_stats JSON blob column to spoolbuddy_devices await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT") + # Migration: Add SSH host key for TOFU verification (H1 security fix) + await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN ssh_host_key VARCHAR(500)") + # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer. # PostgreSQL gets the correct schema from create_all(), so skip this diff --git a/backend/app/main.py b/backend/app/main.py index a44462b0f..1dcad5565 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,6 +50,7 @@ from backend.app.api.routes import ( smart_plugs, spoolbuddy, spoolman, + spoolman_inventory, support, system, updates, @@ -4543,6 +4544,7 @@ app.include_router(notifications.router, prefix=app_settings.api_prefix) app.include_router(notification_templates.router, prefix=app_settings.api_prefix) app.include_router(user_notifications.router, prefix=app_settings.api_prefix) app.include_router(spoolman.router, prefix=app_settings.api_prefix) +app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix) app.include_router(updates.router, prefix=app_settings.api_prefix) app.include_router(maintenance.router, prefix=app_settings.api_prefix) app.include_router(camera.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/spool.py b/backend/app/models/spool.py index 91c79df45..2e620bc1e 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -36,9 +36,11 @@ class Spool(Base): # Cost tracking cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram + storage_location: Mapped[str | None] = mapped_column(String(255)) # User-editable storage location + last_used: Mapped[datetime | None] = mapped_column(DateTime) # Last time this spool was used in a print encode_time: Mapped[datetime | None] = mapped_column(DateTime) # When spool was encoded/written to tag - tag_uid: Mapped[str | None] = mapped_column(String(16)) # RFID tag UID (16 hex chars) + tag_uid: Mapped[str | None] = mapped_column(String(32)) # RFID tag UID (up to 32 hex chars) tray_uuid: Mapped[str | None] = mapped_column(String(32)) # Bambu Lab spool UUID (32 hex chars) data_origin: Mapped[str | None] = mapped_column(String(20)) # How data was populated: manual, rfid_auto, nfc_link tag_type: Mapped[str | None] = mapped_column(String(20)) # Tag vendor: bambulab, generic, etc. diff --git a/backend/app/models/spoolbuddy_device.py b/backend/app/models/spoolbuddy_device.py index 14366ecf7..c6a22da03 100644 --- a/backend/app/models/spoolbuddy_device.py +++ b/backend/app/models/spoolbuddy_device.py @@ -37,5 +37,6 @@ class SpoolBuddyDevice(Base): scale_ok: Mapped[bool] = mapped_column(Boolean, default=False) uptime_s: Mapped[int] = mapped_column(Integer, default=0) system_stats: Mapped[str | None] = mapped_column(Text, nullable=True) + ssh_host_key: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/spool.py b/backend/app/schemas/spool.py index df63fe8a8..abfe2fc18 100644 --- a/backend/app/schemas/spool.py +++ b/backend/app/schemas/spool.py @@ -18,7 +18,7 @@ class SpoolBase(BaseModel): nozzle_temp_min: int | None = None nozzle_temp_max: int | None = None note: str | None = None - tag_uid: str | None = None + tag_uid: str | None = Field(default=None, max_length=32) tray_uuid: str | None = None data_origin: str | None = None tag_type: str | None = None @@ -26,6 +26,7 @@ class SpoolBase(BaseModel): weight_locked: bool = False last_scale_weight: int | None = None last_weighed_at: datetime | None = None + storage_location: str | None = Field(default=None, max_length=255) class SpoolCreate(SpoolBase): @@ -52,12 +53,13 @@ class SpoolUpdate(BaseModel): nozzle_temp_min: int | None = None nozzle_temp_max: int | None = None note: str | None = None - tag_uid: str | None = None + tag_uid: str | None = Field(default=None, max_length=32) tray_uuid: str | None = None data_origin: str | None = None tag_type: str | None = None cost_per_kg: float | None = Field(default=None, ge=0) weight_locked: bool | None = None + storage_location: str | None = Field(default=None, max_length=255) class SpoolKProfileBase(BaseModel): @@ -90,7 +92,7 @@ class SpoolResponse(SpoolBase): added_full: bool | None = None last_used: datetime | None = None encode_time: datetime | None = None - tag_uid: str | None = None + tag_uid: str | None = Field(default=None, max_length=32) tray_uuid: str | None = None data_origin: str | None = None tag_type: str | None = None diff --git a/backend/app/schemas/spoolbuddy.py b/backend/app/schemas/spoolbuddy.py index 87044385e..e5263b8da 100644 --- a/backend/app/schemas/spoolbuddy.py +++ b/backend/app/schemas/spoolbuddy.py @@ -1,6 +1,8 @@ +import json from datetime import datetime +from typing import Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator # --- Device schemas --- @@ -9,14 +11,14 @@ class DeviceRegisterRequest(BaseModel): device_id: str = Field(..., min_length=1, max_length=50) hostname: str = Field(..., min_length=1, max_length=100) ip_address: str = Field(..., min_length=1, max_length=45) - firmware_version: str | None = None + firmware_version: str | None = Field(None, max_length=20) has_nfc: bool = True has_scale: bool = True tare_offset: int = 0 calibration_factor: float = 1.0 - nfc_reader_type: str | None = None - nfc_connection: str | None = None - backend_url: str | None = None + nfc_reader_type: str | None = Field(None, max_length=20) + nfc_connection: str | None = Field(None, max_length=20) + backend_url: str | None = Field(None, max_length=255) has_backlight: bool = False @@ -58,13 +60,20 @@ class HeartbeatRequest(BaseModel): nfc_ok: bool = False scale_ok: bool = False uptime_s: int = 0 - firmware_version: str | None = None - ip_address: str | None = None - nfc_reader_type: str | None = None - nfc_connection: str | None = None - backend_url: str | None = None + firmware_version: str | None = Field(None, max_length=20) + ip_address: str | None = Field(None, max_length=45) + nfc_reader_type: str | None = Field(None, max_length=20) + nfc_connection: str | None = Field(None, max_length=20) + backend_url: str | None = Field(None, max_length=255) system_stats: dict | None = None + @field_validator("system_stats") + @classmethod + def _limit_system_stats_size(cls, v: dict | None) -> dict | None: + if v is not None and len(json.dumps(v)) > 4096: + raise ValueError("system_stats must not exceed 4096 bytes when JSON-encoded") + return v + class HeartbeatResponse(BaseModel): pending_command: str | None = None @@ -80,31 +89,31 @@ class HeartbeatResponse(BaseModel): class TagScannedRequest(BaseModel): - device_id: str - tag_uid: str - tray_uuid: str | None = None + device_id: str = Field(..., max_length=50) + tag_uid: str = Field(..., max_length=32) + tray_uuid: str | None = Field(None, max_length=32, pattern=r"^[0-9A-Fa-f]*$") sak: int | None = None - tag_type: str | None = None + tag_type: str | None = Field(None, max_length=50) raw_blocks: dict | None = None class TagRemovedRequest(BaseModel): - device_id: str - tag_uid: str + device_id: str = Field(..., max_length=50) + tag_uid: str = Field(..., max_length=32) # --- Scale schemas --- class ScaleReadingRequest(BaseModel): - device_id: str - weight_grams: float + device_id: str = Field(..., max_length=50) + weight_grams: float = Field(..., ge=0.0, le=100_000.0) stable: bool = False raw_adc: int | None = None class UpdateSpoolWeightRequest(BaseModel): - spool_id: int + spool_id: int = Field(..., gt=0) weight_grams: float @@ -130,16 +139,16 @@ class CalibrationResponse(BaseModel): class WriteTagRequest(BaseModel): - device_id: str - spool_id: int + device_id: str = Field(..., max_length=50) + spool_id: int = Field(..., gt=0) class WriteTagResultRequest(BaseModel): - device_id: str - spool_id: int - tag_uid: str + device_id: str = Field(..., max_length=50) + spool_id: int = Field(..., gt=0) + tag_uid: str = Field(..., min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$") success: bool - message: str | None = None + message: str | None = Field(None, max_length=500) class DisplaySettingsRequest(BaseModel): @@ -153,20 +162,27 @@ class SystemConfigRequest(BaseModel): class SystemCommandRequest(BaseModel): - command: str = Field(..., description="System command: reboot, shutdown, restart_daemon, restart_browser") + command: str = Field( + ..., max_length=50, description="System command: reboot, shutdown, restart_daemon, restart_browser" + ) class SystemCommandResultRequest(BaseModel): - command: str + command: str = Field(..., max_length=50) success: bool - message: str | None = None + message: str | None = Field(None, max_length=500) + + +class UpdateStatusRequest(BaseModel): + status: Literal["updating", "complete", "error"] + message: str | None = Field(None, max_length=255) # --- Diagnostics schemas --- class DiagnosticResultRequest(BaseModel): - diagnostic: str # 'nfc', 'scale', or 'read_tag' + diagnostic: str = Field(..., max_length=50, description="Diagnostic type: 'nfc', 'scale', or 'read_tag'") success: bool - output: str - exit_code: int + output: str = Field(..., max_length=10_000) + exit_code: int = Field(..., ge=-255, le=255) diff --git a/backend/app/services/opentag3d.py b/backend/app/services/opentag3d.py index a726b2442..94d3ccffd 100644 --- a/backend/app/services/opentag3d.py +++ b/backend/app/services/opentag3d.py @@ -13,6 +13,7 @@ NDEF structure: """ import struct +from typing import TypedDict from backend.app.models.spool import Spool @@ -21,33 +22,50 @@ PAYLOAD_SIZE = 102 TAG_VERSION = 1000 # v1.000 -def _build_payload(spool: Spool) -> bytes: - """Build 102-byte OpenTag3D core payload from spool fields.""" +class MappedSpoolFields(TypedDict, total=False): + """Fields consumed by the OpenTag3D encoder from a mapped Spoolman spool dict.""" + + material: str | None + subtype: str | None + brand: str | None + color_name: str | None + rgba: str | None + label_weight: int | None + nozzle_temp_min: int | None + + +def _build_payload_from_dict(data: dict) -> bytes: + """Build 102-byte OpenTag3D core payload from a plain field dict. + + Accepted keys: material, subtype, brand, color_name, rgba, + label_weight, nozzle_temp_min. All are optional and default to + safe zero/empty values when missing. + """ buf = bytearray(PAYLOAD_SIZE) # 0x00: Tag Version (2 bytes, big-endian) struct.pack_into(">H", buf, 0x00, TAG_VERSION) # 0x02: Base Material (5 bytes, UTF-8, space-padded) - material = (spool.material or "")[:5].ljust(5) + material = (data.get("material") or "")[:5].ljust(5) buf[0x02:0x07] = material.encode("utf-8")[:5] # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded) - modifiers = (spool.subtype or "")[:5].ljust(5) + modifiers = (data.get("subtype") or "")[:5].ljust(5) buf[0x07:0x0C] = modifiers.encode("utf-8")[:5] # 0x0C: Reserved (15 bytes, zero-fill) — already zero # 0x1B: Manufacturer (16 bytes, UTF-8, space-padded) - brand = (spool.brand or "")[:16].ljust(16) + brand = (data.get("brand") or "")[:16].ljust(16) buf[0x1B:0x2B] = brand.encode("utf-8")[:16] # 0x2B: Color Name (32 bytes, UTF-8, space-padded) - color_name = (spool.color_name or "")[:32].ljust(32) + color_name = (data.get("color_name") or "")[:32].ljust(32) buf[0x2B:0x4B] = color_name.encode("utf-8")[:32] # 0x4B: Color 1 RGBA (4 bytes) - rgba_hex = spool.rgba or "00000000" + rgba_hex = data.get("rgba") or "00000000" try: rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0")) except ValueError: @@ -59,11 +77,12 @@ def _build_payload(spool: Spool) -> bytes: # 0x5C: Target Diameter (2 bytes, big-endian) — 1750 = 1.75mm struct.pack_into(">H", buf, 0x5C, 1750) - # 0x5E: Target Weight (2 bytes, big-endian) - struct.pack_into(">H", buf, 0x5E, spool.label_weight or 0) + # 0x5E: Target Weight (2 bytes, big-endian) — clamped to uint16 (0–65535) + label_weight = max(0, min(int(data.get("label_weight") or 0), 65535)) + struct.pack_into(">H", buf, 0x5E, label_weight) - # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5 - buf[0x60] = (spool.nozzle_temp_min or 0) // 5 + # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5, clamped to 0–255 + buf[0x60] = max(0, min(int((data.get("nozzle_temp_min") or 0) // 5), 255)) # 0x61: Bed Temp (1 byte) — not tracked # 0x62: Density (2 bytes) — not tracked @@ -73,17 +92,26 @@ def _build_payload(spool: Spool) -> bytes: return bytes(buf) -def encode_opentag3d(spool: Spool) -> bytes: - """Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator). +def _build_payload(spool: Spool) -> bytes: + """Build 102-byte OpenTag3D core payload from a Spool ORM object.""" + return _build_payload_from_dict( + { + "material": spool.material, + "subtype": spool.subtype, + "brand": spool.brand, + "color_name": spool.color_name, + "rgba": spool.rgba, + "label_weight": spool.label_weight, + "nozzle_temp_min": spool.nozzle_temp_min, + } + ) - Returns raw bytes ready to write to NTAG starting at page 4. - """ - payload = _build_payload(spool) + +def _encode_ndef(payload: bytes) -> bytes: + """Wrap a 102-byte payload in CC + TLV + NDEF record + terminator.""" mime_type = OPENTAG3D_MIME_TYPE # NDEF record: MB|ME|SR (0xD0) | TNF=MIME (0x02) => 0xD2 - # Type length = 21 - # Payload length = 102 (fits in SR single byte) record_header = bytes([0xD2, len(mime_type), len(payload)]) ndef_record = record_header + mime_type + payload @@ -94,10 +122,24 @@ def encode_opentag3d(spool: Spool) -> bytes: else: tlv = bytes([0x03, 0xFF, (ndef_len >> 8) & 0xFF, ndef_len & 0xFF]) - # Capability Container (page 4) cc = bytes([0xE1, 0x10, 0x12, 0x00]) - - # Terminator TLV terminator = bytes([0xFE]) - return cc + tlv + ndef_record + terminator + + +def encode_opentag3d(spool: Spool) -> bytes: + """Encode spool ORM object as OpenTag3D NDEF message. + + Returns raw bytes ready to write to NTAG starting at page 4. + """ + return _encode_ndef(_build_payload(spool)) + + +def encode_opentag3d_from_mapped(mapped: MappedSpoolFields) -> bytes: + """Encode a Spoolman-mapped spool dict as OpenTag3D NDEF message. + + Accepts the dict produced by ``_map_spoolman_spool`` (or any dict + with the same field names). Returns raw bytes ready to write to + NTAG starting at page 4. + """ + return _encode_ndef(_build_payload_from_dict(mapped)) diff --git a/backend/app/services/spoolbuddy_ssh.py b/backend/app/services/spoolbuddy_ssh.py index a91094768..7a7ddf961 100644 --- a/backend/app/services/spoolbuddy_ssh.py +++ b/backend/app/services/spoolbuddy_ssh.py @@ -15,6 +15,7 @@ entries for root). asyncssh does all of its work in-process. import asyncio import logging import os +import shlex from pathlib import Path import asyncssh @@ -139,45 +140,61 @@ async def _run_ssh_command( ip: str, command: str, private_key: Path, + *, + known_hosts: "asyncssh.SSHKnownHosts | None" = None, timeout: int = 60, -) -> tuple[int, str, str]: +) -> tuple[int, str, str, str | None]: """Execute a command on a SpoolBuddy device via SSH. Uses asyncssh rather than the OpenSSH `ssh` binary — see module docstring for the Docker/PUID rationale. - Returns (returncode, stdout, stderr). On connection failure the return - code is 255 (matching `ssh`'s own convention) and stderr carries the - asyncssh error message. On timeout the return code is -1. + Returns (returncode, stdout, stderr, observed_host_key). + observed_host_key is non-None only on a successful connection when known_hosts=None + was passed. Callers are responsible for also checking whether a stored key already + exists before persisting — use `observed_key and not stored_host_key` not just + `observed_key is not None`. + On connection failure rc=255; on timeout rc=-1. """ + observed_host_key: str | None = None try: async with asyncio.timeout(timeout): async with asyncssh.connect( host=ip, username=SSH_USER, client_keys=[str(private_key)], - known_hosts=None, # equivalent to StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null + known_hosts=known_hosts, config=[], # do not load ~/.ssh/config — HOME may not resolve under arbitrary Docker PUIDs connect_timeout=10, ) as conn: + if known_hosts is None: + # TOFU first-use: capture the host key for storage + server_key = conn.get_server_host_key() + if server_key: + observed_host_key = server_key.export_public_key("openssh").decode().strip() result = await conn.run(command, check=False) + except asyncssh.HostKeyNotVerifiable: + logger.error("SSH host key mismatch for %s — possible MITM attack", ip) + return 255, "", "Host key mismatch — verify device identity before retrying", None except TimeoutError: - return -1, "", "SSH command timed out" + return -1, "", "SSH command timed out", None except (asyncssh.Error, OSError) as exc: - return 255, "", str(exc) + return 255, "", str(exc), None stdout = result.stdout if isinstance(result.stdout, str) else (result.stdout or b"").decode(errors="replace") stderr = result.stderr if isinstance(result.stderr, str) else (result.stderr or b"").decode(errors="replace") # asyncssh's exit_status is None when the remote closed without setting one returncode = result.exit_status if result.exit_status is not None else 0 - return returncode, stdout, stderr + return returncode, stdout, stderr, observed_host_key async def perform_ssh_update(device_id: str, ip_address: str, install_path: str | None = None) -> None: """SSH into a SpoolBuddy device and update it to match Bambuddy's branch. Updates device.update_status/update_message in the DB and broadcasts - progress via WebSocket at each step. + progress via WebSocket at each step. Host key verification uses TOFU: + the device's SSH public key is stored on first connect and verified on + all subsequent connections. """ from sqlalchemy import select @@ -187,6 +204,27 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str install_path = install_path or DEFAULT_INSTALL_PATH branch = detect_current_branch() + safe_branch = shlex.quote(branch) + safe_path = shlex.quote(install_path) + + # Load the stored SSH host key for TOFU verification + stored_host_key: str | None = None + async with async_session() as db: + result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id)) + dev = result.scalar_one_or_none() + if dev: + stored_host_key = dev.ssh_host_key + + known_hosts: asyncssh.SSHKnownHosts | None = None + if stored_host_key: + try: + known_hosts = asyncssh.import_known_hosts(f"{ip_address} {stored_host_key}\n".encode()) + except (ValueError, asyncssh.Error) as exc: + logger.warning( + "Could not parse stored SSH host key for %s, falling back to TOFU: %s", + device_id, + exc, + ) async def _update_progress(status: str, message: str) -> None: """Update device status in DB and broadcast via WebSocket.""" @@ -214,17 +252,40 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str # Step 1: Test SSH connectivity await _update_progress("updating", "Connecting via SSH...") - rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key) + rc, _, stderr, observed_key = await _run_ssh_command( + ip_address, "echo ok", private_key, known_hosts=known_hosts + ) if rc != 0: await _update_progress("error", f"SSH connection failed: {stderr[:200]}") return + # TOFU: persist host key on first successful connect + if observed_key and not stored_host_key: + async with async_session() as db: + result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id)) + d = result.scalar_one_or_none() + if d: + d.ssh_host_key = observed_key + await db.commit() + logger.info("TOFU: stored SSH host key for SpoolBuddy %s", device_id) + try: + known_hosts = asyncssh.import_known_hosts(f"{ip_address} {observed_key}\n".encode()) + except (ValueError, asyncssh.Error) as exc: + logger.error( + "TOFU: could not parse just-stored host key for %s; " + "remaining SSH steps in this run will not verify host key: %s", + device_id, + exc, + ) + known_hosts = None + # Step 2: Git fetch await _update_progress("updating", f"Fetching latest code (branch: {branch})...") - rc, _, stderr = await _run_ssh_command( + rc, _, stderr, _ = await _run_ssh_command( ip_address, - f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}", + f"cd {safe_path} && git -c safe.directory={safe_path} fetch origin {safe_branch}", private_key, + known_hosts=known_hosts, timeout=120, ) if rc != 0: @@ -233,11 +294,12 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str # Step 3: Git checkout + reset await _update_progress("updating", "Applying update...") - rc, _, stderr = await _run_ssh_command( + rc, _, stderr, _ = await _run_ssh_command( ip_address, - f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} " - f"&& git -c safe.directory={install_path} reset --hard origin/{branch}", + f"cd {safe_path} && git -c safe.directory={safe_path} checkout {safe_branch} " + f"&& git -c safe.directory={safe_path} reset --hard origin/{safe_branch}", private_key, + known_hosts=known_hosts, ) if rc != 0: await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}") @@ -245,11 +307,12 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str # Step 4: Install dependencies await _update_progress("updating", "Installing dependencies...") - venv_pip = f"{install_path}/spoolbuddy/venv/bin/pip" - rc, _, stderr = await _run_ssh_command( + venv_pip = shlex.quote(f"{install_path}/spoolbuddy/venv/bin/pip") + rc, _, stderr, _ = await _run_ssh_command( ip_address, f"{venv_pip} install --upgrade spidev gpiod smbus2 httpx 2>&1", private_key, + known_hosts=known_hosts, timeout=120, ) if rc != 0: @@ -257,10 +320,11 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str # Step 5: Restart daemon await _update_progress("updating", "Restarting daemon...") - rc, _, stderr = await _run_ssh_command( + rc, _, stderr, _ = await _run_ssh_command( ip_address, "sudo /usr/bin/systemctl restart spoolbuddy.service", private_key, + known_hosts=known_hosts, ) if rc != 0: await _update_progress("error", f"Service restart failed: {stderr[:200]}") @@ -272,17 +336,19 @@ async def perform_ssh_update(device_id: str, ip_address: str, install_path: str ip_address, "sudo find /home -maxdepth 5 -path '*/chromium/Default/Service Worker' -type d -exec rm -rf {} + 2>/dev/null; true", private_key, + known_hosts=known_hosts, ) - rc, _, stderr = await _run_ssh_command( + rc, _, stderr, _ = await _run_ssh_command( ip_address, "sudo /usr/bin/systemctl restart getty@tty1.service", private_key, + known_hosts=known_hosts, ) if rc != 0: logger.warning("SpoolBuddy %s: kiosk restart failed (non-fatal): %s", device_id, stderr[:200]) logger.info("SpoolBuddy %s: SSH update complete (branch=%s)", device_id, branch) - except Exception as e: - logger.error("SpoolBuddy %s: SSH update failed: %s", device_id, e) - await _update_progress("error", f"Update failed: {str(e)[:200]}") + except Exception: + logger.exception("SpoolBuddy %s: SSH update failed", device_id) + await _update_progress("error", "Update failed due to an internal error") diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index b878bb5aa..39d64276e 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -4,6 +4,7 @@ import asyncio import logging from dataclasses import dataclass from datetime import datetime, timezone +from typing import Literal import httpx @@ -56,6 +57,25 @@ class AMSTray: tray_weight: int # Spool weight in grams (usually 1000) +class SpoolmanNotFoundError(Exception): + """Raised when a spool ID does not exist in Spoolman (HTTP 404).""" + + +class SpoolmanUnavailableError(Exception): + """Raised when Spoolman is unreachable or returns a server/network error.""" + + +class SpoolmanClientError(Exception): + """Raised when Spoolman returns a 4xx client error (not 404). + + Indicates the request was malformed or rejected by Spoolman, not a connectivity failure. + """ + + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + class SpoolmanClient: """Client for interacting with Spoolman API.""" @@ -69,6 +89,8 @@ class SpoolmanClient: self.api_url = f"{self.base_url}/api/v1" self._client: httpx.AsyncClient | None = None self._connected = False + # Per-spool locks for atomic read-modify-write in merge_spool_extra. + self._extra_locks: dict[int, asyncio.Lock] = {} async def _get_client(self) -> httpx.AsyncClient: """Get or create the HTTP client with connection pooling limits. @@ -80,7 +102,9 @@ class SpoolmanClient: """ if self._client is None: self._client = httpx.AsyncClient( - timeout=10.0, + timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0), + follow_redirects=False, + verify=True, limits=httpx.Limits( max_keepalive_connections=5, max_connections=10, @@ -169,13 +193,16 @@ class SpoolmanClient: await asyncio.sleep(retry_delay) else: logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e) - raise + raise SpoolmanUnavailableError("Cannot reach Spoolman") from e async def get_filaments(self) -> list[dict]: """Get all internal filaments from Spoolman. Returns: List of filament dictionaries. + + Raises: + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. """ try: client = await self._get_client() @@ -184,13 +211,16 @@ class SpoolmanClient: return response.json() except Exception as e: logger.error("Failed to get filaments from Spoolman: %s", e) - return [] + raise SpoolmanUnavailableError("Cannot reach Spoolman") from e async def get_external_filaments(self) -> list[dict]: """Get external/library filaments from Spoolman. Returns: List of external filament dictionaries. + + Raises: + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. """ try: client = await self._get_client() @@ -199,13 +229,16 @@ class SpoolmanClient: return response.json() except Exception as e: logger.error("Failed to get external filaments from Spoolman: %s", e) - return [] + raise SpoolmanUnavailableError("Cannot reach Spoolman") from e async def get_vendors(self) -> list[dict]: """Get all vendors from Spoolman. Returns: List of vendor dictionaries. + + Raises: + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. """ try: client = await self._get_client() @@ -214,7 +247,7 @@ class SpoolmanClient: return response.json() except Exception as e: logger.error("Failed to get vendors from Spoolman: %s", e) - return [] + raise SpoolmanUnavailableError("Cannot reach Spoolman") from e async def create_vendor(self, name: str) -> dict | None: """Create a new vendor in Spoolman. @@ -274,6 +307,7 @@ class SpoolmanClient: vendor_id: int | None = None, material: str | None = None, color_hex: str | None = None, + color_name: str | None = None, weight: float | None = None, diameter: float = 1.75, density: float | None = None, @@ -285,6 +319,7 @@ class SpoolmanClient: vendor_id: Vendor ID material: Material type (PLA, PETG, etc.) color_hex: Color in hex format (without #) + color_name: Human-readable colour name (e.g. "Bambu Green") weight: Net weight in grams diameter: Filament diameter in mm (default 1.75) density: Filament density in g/cm³ (auto-calculated if not provided) @@ -315,6 +350,8 @@ class SpoolmanClient: # Strip alpha channel if present (RRGGBBAA -> RRGGBB) color_hex = color_hex[:6] if len(color_hex) >= 6 else color_hex data["color_hex"] = color_hex + if color_name: + data["color_name"] = color_name if weight: data["weight"] = weight @@ -421,6 +458,278 @@ class SpoolmanClient: logger.error("Failed to update spool in Spoolman: %s", e) return None + async def _request_spool( + self, + method: Literal["GET", "PATCH", "DELETE"], + spool_id: int, + *, + json_body: dict | None = None, + operation: str, + ) -> httpx.Response: + """Perform a spool-scoped HTTP request, translating 404 and errors to named exceptions.""" + try: + client = await self._get_client() + response = await client.request( + method, + f"{self.api_url}/spool/{spool_id}", + json=json_body, + ) + if response.status_code == 404: + raise SpoolmanNotFoundError(f"Spool {spool_id} not found in Spoolman") + response.raise_for_status() + return response + except SpoolmanNotFoundError: + raise + except httpx.HTTPStatusError as e: + if 400 <= e.response.status_code < 500: + logger.warning( + "Spoolman returned %d for %s spool %s", + e.response.status_code, + operation, + spool_id, + ) + raise SpoolmanClientError( + f"Spoolman rejected {operation} for spool {spool_id} (HTTP {e.response.status_code})", + e.response.status_code, + ) from e + else: + logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e) + raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e + except Exception as e: + logger.error("Failed to %s spool %s in Spoolman: %s", operation, spool_id, e) + raise SpoolmanUnavailableError(f"Failed to {operation} spool {spool_id}") from e + + async def get_spool(self, spool_id: int) -> dict: + """Get a single spool by ID from Spoolman. + + Args: + spool_id: Spoolman spool ID + + Returns: + Spool dictionary. + + Raises: + SpoolmanNotFoundError: If the spool does not exist (HTTP 404). + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. + """ + response = await self._request_spool("GET", spool_id, operation="get") + return response.json() + + async def get_all_spools(self, allow_archived: bool = False) -> list[dict]: + """Get all spools from Spoolman, optionally including archived ones. + + Args: + allow_archived: If True, include archived spools in the result. + + Returns: + List of spool dictionaries. + + Raises: + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. + """ + try: + client = await self._get_client() + params: dict = {} + if allow_archived: + params["allow_archived"] = "true" + response = await client.get(f"{self.api_url}/spool", params=params or None) + response.raise_for_status() + return response.json() + except Exception as e: + logger.error("Failed to get all spools from Spoolman: %s", e) + raise SpoolmanUnavailableError("Cannot reach Spoolman") from e + + async def delete_spool(self, spool_id: int) -> None: + """Delete a spool from Spoolman. + + Args: + spool_id: Spoolman spool ID + + Raises: + SpoolmanNotFoundError: If the spool does not exist (HTTP 404). + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. + """ + await self._request_spool("DELETE", spool_id, operation="delete") + + async def set_spool_archived(self, spool_id: int, archived: bool) -> dict: + """Archive or restore a spool in Spoolman. + + Args: + spool_id: Spoolman spool ID + archived: True to archive, False to restore. + + Returns: + Updated spool dictionary. + + Raises: + SpoolmanNotFoundError: If the spool does not exist (HTTP 404). + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. + """ + response = await self._request_spool( + "PATCH", + spool_id, + json_body={"archived": archived}, + operation="archive/restore", + ) + return response.json() + + async def update_spool_full( + self, + spool_id: int, + *, + filament_id: int | None = None, + remaining_weight: float | None = None, + comment: str | None = None, + price: float | None = None, + location: str | None = None, + clear_location: bool = False, + extra: dict | None = None, + ) -> dict: + """Update a spool in Spoolman with comprehensive field support. + + Unlike update_spool, this method does not auto-set last_used and + supports updating filament_id, comment, and price. + + Args: + spool_id: Spoolman spool ID + filament_id: New filament type ID + remaining_weight: New remaining weight in grams + comment: New comment/note (pass empty string to clear) + price: Cost per unit (maps to cost_per_kg usage) + location: New location string + clear_location: If True, sets location to None + extra: Extra fields dict to replace (overwrites the entire extra dict; + use merge_spool_extra to preserve other fields) + + Returns: + Updated spool dictionary. + + Raises: + SpoolmanNotFoundError: If the spool does not exist (HTTP 404). + SpoolmanUnavailableError: If Spoolman is unreachable or returns a server error. + """ + data: dict = {} + if filament_id is not None: + data["filament_id"] = filament_id + if remaining_weight is not None: + data["remaining_weight"] = remaining_weight + if comment is not None: + data["comment"] = comment if comment else None + if price is not None: + data["price"] = price + if clear_location: + data["location"] = None + elif location is not None: + data["location"] = location + if extra is not None: + data["extra"] = extra + + response = await self._request_spool("PATCH", spool_id, json_body=data, operation="update") + return response.json() + + def extra_lock(self, spool_id: int) -> asyncio.Lock: + """Return (creating if needed) the per-spool asyncio.Lock used by merge_spool_extra.""" + return self._extra_locks.setdefault(spool_id, asyncio.Lock()) + + async def merge_spool_extra(self, spool_id: int, new_fields: dict) -> dict: + """Fetch current extra dict, merge new_fields in, then PATCH back to Spoolman. + + Safe merge — never blindly overwrites other custom Spoolman extra fields. + The operation is serialised per spool_id with an asyncio.Lock to prevent + concurrent calls from clobbering each other's writes. + + Args: + spool_id: Spoolman spool ID + new_fields: Fields to add/update in the extra dict + + Returns: + Updated spool dictionary. + + Raises: + SpoolmanNotFoundError: If the spool does not exist. + SpoolmanUnavailableError: If Spoolman is unreachable. + """ + async with self.extra_lock(spool_id): + current = await self.get_spool(spool_id) # raises on error + current_extra: dict = current.get("extra") or {} + merged = {**current_extra, **new_fields} + return await self.update_spool_full(spool_id=spool_id, extra=merged) + + async def find_or_create_vendor(self, name: str) -> int | None: + """Find an existing vendor by name or create a new one. + + Args: + name: Vendor name (case-insensitive match) + + Returns: + Vendor ID or None on failure. + """ + vendors = await self.get_vendors() + name_lower = name.strip().lower() + for vendor in vendors: + if vendor.get("name", "").strip().lower() == name_lower: + return vendor["id"] + created = await self.create_vendor(name.strip()) + return created["id"] if created else None + + async def find_or_create_filament( + self, + material: str, + subtype: str, + brand: str | None, + color_hex: str, + label_weight: int, + color_name: str | None = None, + ) -> int | None: + """Find a matching filament in Spoolman or create a new one. + + Matching uses material + full name + vendor + color_hex (normalised). + A new filament is created only when no exact match is found. + + Args: + material: Filament material (e.g. "PLA") + subtype: Filament subtype (e.g. "Basic"); combined with material as name + brand: Vendor/brand name; None skips vendor matching + color_hex: 6-char hex colour string (RRGGBB, no #) + label_weight: Net spool weight in grams + color_name: Human-readable colour name passed to create_filament when creating + + Returns: + Filament ID or None on failure. + """ + name = f"{material} {subtype}".strip() if subtype else material + color = color_hex[:6].upper() if len(color_hex) >= 6 else color_hex.upper() + + vendor_id: int | None = None + if brand: + vendor_id = await self.find_or_create_vendor(brand) + + filaments = await self.get_filaments() + for f in filaments: + f_material = (f.get("material") or "").upper() + f_name = (f.get("name") or "").strip() + f_color = (f.get("color_hex") or "").upper()[:6] + f_vendor = f.get("vendor") or {} + f_vendor_name = (f_vendor.get("name") or "").strip().lower() + + material_match = f_material == material.upper() + name_match = f_name.lower() == name.lower() + color_match = f_color == color + vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower() + + if material_match and name_match and color_match and vendor_match: + return f["id"] + + filament = await self.create_filament( + name=name, + vendor_id=vendor_id, + material=material, + color_hex=color, + color_name=color_name, + weight=float(label_weight), + ) + return filament["id"] if filament else None + async def use_spool(self, spool_id: int, used_weight: float) -> dict | None: """Record filament usage for a spool. @@ -972,7 +1281,14 @@ async def init_spoolman_client(url: str) -> SpoolmanClient: Returns: Initialized SpoolmanClient instance. + + Raises: + ValueError: If *url* is rejected by the SSRF guard. """ + from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url + + assert_safe_spoolman_url(url) + global _spoolman_client if _spoolman_client: await _spoolman_client.close() diff --git a/backend/app/services/spoolman_tracking.py b/backend/app/services/spoolman_tracking.py index 91b6aa58b..973e38ecd 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/backend/app/services/spoolman_tracking.py @@ -71,6 +71,7 @@ def _resolve_spool_tag(tray_info: dict, printer_serial: str = "", global_tray_id """ tray_uuid = str(tray_info.get("tray_uuid", "") or "") tag_uid = str(tray_info.get("tag_uid", "") or "") + if tray_uuid and tray_uuid != _ZERO_UUID and _is_non_zero_identifier(tray_uuid): return tray_uuid if tag_uid and tag_uid != _ZERO_TAG_UID and _is_non_zero_identifier(tag_uid): @@ -312,9 +313,16 @@ async def _get_spoolman_client_with_fallback(): spoolman_url = await get_setting(db, "spoolman_url") if spoolman_url: - client = await init_spoolman_client(spoolman_url) + try: + client = await init_spoolman_client(spoolman_url) + except ValueError as exc: + logger.warning("Spoolman URL %r rejected by SSRF guard: %s", spoolman_url, exc) + return None - if not client or not await client.health_check(): + if not client: + return None + if not await client.health_check(): + logger.warning("Spoolman health check failed; skipping usage reporting") return None return client diff --git a/backend/tests/integration/test_auth_apikey_rbac.py b/backend/tests/integration/test_auth_apikey_rbac.py new file mode 100644 index 000000000..bf2d287df --- /dev/null +++ b/backend/tests/integration/test_auth_apikey_rbac.py @@ -0,0 +1,160 @@ +"""Integration tests for API key RBAC enforcement (security fix C1).""" + +import pytest +from httpx import AsyncClient + + +@pytest.fixture +async def api_key_data(async_client: AsyncClient, db_session): + """Create an API key and return its full key value.""" + from backend.app.core.auth import generate_api_key + from backend.app.models.api_key import APIKey + + full_key, key_hash, key_prefix = generate_api_key() + api_key = APIKey( + name="test-key", + key_hash=key_hash, + key_prefix=key_prefix, + can_queue=True, + can_control_printer=True, + can_read_status=True, + enabled=True, + ) + db_session.add(api_key) + await db_session.commit() + return full_key + + +@pytest.fixture +async def spoolman_settings(db_session): + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value="http://localhost:7912")) + await db_session.commit() + + +class TestApiKeyRbacDenied: + """API keys must be refused for admin-only endpoints.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_api_key_cannot_access_settings_update_endpoint( + self, async_client: AsyncClient, db_session, api_key_data + ): + """API key must not be usable for settings:update endpoints (C1).""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="auth_enabled", value="true")) + await db_session.commit() + + resp = await async_client.put( + "/api/v1/settings/", + json={}, + headers={"X-API-Key": api_key_data}, + ) + assert resp.status_code == 403 + assert "administrative operations" in resp.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_api_key_bearer_cannot_access_settings_update( + self, async_client: AsyncClient, db_session, api_key_data + ): + """Bearer bb_ API key must also be refused for settings:update (C1).""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="auth_enabled", value="true")) + await db_session.commit() + + resp = await async_client.put( + "/api/v1/settings/", + json={}, + headers={"Authorization": f"Bearer {api_key_data}"}, + ) + assert resp.status_code == 403 + assert "administrative operations" in resp.json()["detail"] + + +class TestApiKeyRbacAllowed: + """API keys must still work for non-admin endpoints.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_api_key_can_access_inventory_read( + self, async_client: AsyncClient, db_session, api_key_data, spoolman_settings + ): + """API key must be accepted for inventory:read endpoints (C1).""" + from unittest.mock import AsyncMock, MagicMock, patch + + from backend.app.models.settings import Settings + + db_session.add(Settings(key="auth_enabled", value="true")) + await db_session.commit() + + mock_client = MagicMock() + mock_client.base_url = "http://localhost:7912" + mock_client.health_check = AsyncMock(return_value=True) + mock_client.get_all_spools = AsyncMock(return_value=[]) + with patch( + "backend.app.api.routes.spoolman_inventory._get_client", + AsyncMock(return_value=mock_client), + ): + resp = await async_client.get( + "/api/v1/spoolman/inventory/spools", + headers={"X-API-Key": api_key_data}, + ) + assert resp.status_code == 200 + + +class TestApiKeyDenylistIntegrity: + """Drift-detection: assert that admin-tier permissions remain in the denylist.""" + + def test_admin_permissions_are_denied_for_api_keys(self): + """All known admin-tier permissions must be in _APIKEY_DENIED_PERMISSIONS (H1 guard).""" + from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS + from backend.app.core.permissions import Permission + + expected_denied = { + Permission.SETTINGS_READ, + Permission.SETTINGS_UPDATE, + Permission.SETTINGS_BACKUP, + Permission.SETTINGS_RESTORE, + Permission.USERS_READ, + Permission.USERS_CREATE, + Permission.USERS_UPDATE, + Permission.USERS_DELETE, + Permission.GROUPS_READ, + Permission.GROUPS_CREATE, + Permission.GROUPS_UPDATE, + Permission.GROUPS_DELETE, + Permission.API_KEYS_READ, + Permission.API_KEYS_CREATE, + Permission.API_KEYS_UPDATE, + Permission.API_KEYS_DELETE, + Permission.GITHUB_BACKUP, + Permission.GITHUB_RESTORE, + Permission.FIRMWARE_UPDATE, + } + missing = expected_denied - _APIKEY_DENIED_PERMISSIONS + assert not missing, ( + f"Admin-tier permissions not in API key denylist (add them to _APIKEY_DENIED_PERMISSIONS): {missing}" + ) + + def test_operational_permissions_are_allowed_for_api_keys(self): + """Core operational permissions must NOT be in the denylist.""" + from backend.app.core.auth import _APIKEY_DENIED_PERMISSIONS + from backend.app.core.permissions import Permission + + expected_allowed = { + Permission.INVENTORY_READ, + Permission.INVENTORY_CREATE, + Permission.INVENTORY_UPDATE, + Permission.PRINTERS_READ, + Permission.PRINTERS_CONTROL, + Permission.ARCHIVES_READ, + } + incorrectly_denied = expected_allowed & _APIKEY_DENIED_PERMISSIONS + assert not incorrectly_denied, ( + f"Operational permissions incorrectly in API key denylist: {incorrectly_denied}" + ) diff --git a/backend/tests/integration/test_spoolbuddy.py b/backend/tests/integration/test_spoolbuddy.py index 05bd051e2..a5794efef 100644 --- a/backend/tests/integration/test_spoolbuddy.py +++ b/backend/tests/integration/test_spoolbuddy.py @@ -7,9 +7,11 @@ import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession +import backend.app.services.spoolbuddy_ssh # noqa: F401 — ensures patch() can resolve the dotted path from backend.app.api.routes import spoolbuddy as spoolbuddy_routes from backend.app.models.spool import Spool from backend.app.models.spoolbuddy_device import SpoolBuddyDevice +from backend.app.services.spoolman import SpoolmanNotFoundError, SpoolmanUnavailableError API = "/api/v1/spoolbuddy" @@ -527,7 +529,7 @@ class TestWriteTagEndpoints: json={ "device_id": device.device_id, "spool_id": spool.id, - "tag_uid": "04AABB", + "tag_uid": "04AABBCC", "success": False, "message": "Write or verification failed", }, @@ -559,7 +561,7 @@ class TestWriteTagEndpoints: json={ "device_id": device.device_id, "spool_id": spool.id, - "tag_uid": "AABB", + "tag_uid": "AABBCCDD", "success": True, }, ) @@ -1028,6 +1030,46 @@ class TestUpdateEndpoints: ) assert resp.status_code == 404 + @pytest.mark.asyncio + @pytest.mark.integration + async def test_report_update_status_invalid_status_422(self, async_client: AsyncClient, device_factory): + """Arbitrary status strings must be rejected with 422 (H2: UpdateStatusRequest validation).""" + await device_factory(device_id="sb-upd-inv") + resp = await async_client.post( + f"{API}/devices/sb-upd-inv/update-status", + json={"status": "hacked", "message": "injected"}, + ) + assert resp.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_report_update_status_oversized_message_422(self, async_client: AsyncClient, device_factory): + """Message exceeding 255 chars must be rejected with 422 (H2/M4).""" + await device_factory(device_id="sb-upd-big") + resp = await async_client.post( + f"{API}/devices/sb-upd-big/update-status", + json={"status": "updating", "message": "x" * 256}, + ) + assert resp.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_ssh_public_key_error_does_not_leak_exception_text(self, async_client: AsyncClient): + """SSH public-key 500 must not expose raw exception details (M3).""" + from backend.app.services.spoolbuddy_ssh import get_public_key + + with patch( + "backend.app.services.spoolbuddy_ssh.get_public_key", + AsyncMock(side_effect=RuntimeError("SECRET internal path /data/keys/id_ed25519")), + ): + resp = await async_client.get(f"{API}/ssh/public-key") + + assert resp.status_code == 500 + body = resp.json()["detail"] + assert "SECRET" not in body + assert "/data/keys" not in body + assert "id_ed25519" not in body + @pytest.mark.asyncio @pytest.mark.integration async def test_device_response_includes_update_fields(self, async_client: AsyncClient, device_factory): @@ -1215,3 +1257,1058 @@ class TestSystemCommandEndpoints: assert resp.status_code == 200 data = resp.json() assert data["pending_command"] == "restart_browser" + + +# ============================================================================ +# Spoolman-aware SpoolBuddy endpoints +# ============================================================================ + + +@pytest.fixture +async def spoolman_settings(db_session: AsyncSession): + """Create Spoolman settings in the database (enabled with URL).""" + from backend.app.models.settings import Settings + + settings = [ + Settings(key="spoolman_enabled", value="true"), + Settings(key="spoolman_url", value="http://spoolman.local:7912"), + ] + for s in settings: + db_session.add(s) + await db_session.commit() + return settings + + +def _mock_spoolman_client(base_url: str = "http://spoolman.local:7912") -> MagicMock: + client = MagicMock() + client.base_url = base_url + client.get_spools = AsyncMock(return_value=[]) + client.get_spool = AsyncMock(return_value={}) + client.find_spool_by_tag = AsyncMock(return_value=None) + client.update_spool = AsyncMock(return_value=None) + client.merge_spool_extra = AsyncMock(return_value={"id": 0}) + return client + + +def _spoolman_spool_fixture(spool_id: int, spool_weight: float = 196.0, filament_weight: float = 1000.0) -> dict: + """Build a minimal Spoolman spool dict with realistic core weight from filament.spool_weight.""" + return { + "id": spool_id, + "filament": {"weight": filament_weight, "spool_weight": spool_weight}, + "used_weight": 0.0, + } + + +class TestUpdateSpoolWeightSpoolman: + """update-spool-weight routes to Spoolman when Spoolman mode is active.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_mode_uses_filament_spool_weight(self, async_client: AsyncClient, spoolman_settings): + """core_weight comes from filament.spool_weight, not a hardcoded constant.""" + sm_spool = _spoolman_spool_fixture(42, spool_weight=196.0, filament_weight=1000.0) + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=sm_spool) + mock_client.update_spool = AsyncMock(return_value=sm_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/scale/update-spool-weight", + json={"spool_id": 42, "weight_grams": 750}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + # remaining = max(0, 750 - 196) = 554 → weight_used = 1000 - 554 = 446 + assert data["weight_used"] == pytest.approx(446.0) + mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(554.0)) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_mode_clamps_remaining_to_zero(self, async_client: AsyncClient, spoolman_settings): + """Scale weight below core weight → remaining_weight = 0.""" + sm_spool = _spoolman_spool_fixture(7, spool_weight=196.0, filament_weight=1000.0) + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=sm_spool) + mock_client.update_spool = AsyncMock(return_value=sm_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/scale/update-spool-weight", + json={"spool_id": 7, "weight_grams": 100}, + ) + + assert resp.status_code == 200 + mock_client.update_spool.assert_called_once_with(spool_id=7, remaining_weight=0.0) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_mode_404_when_spool_not_found(self, async_client: AsyncClient, spoolman_settings): + """404 when Spoolman doesn't know the spool.""" + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found")) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/scale/update-spool-weight", + json={"spool_id": 9999, "weight_grams": 500}, + ) + + assert resp.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_mode_502_on_client_failure(self, async_client: AsyncClient, spoolman_settings): + """502 is returned when Spoolman client update fails (returns None).""" + sm_spool = _spoolman_spool_fixture(99) + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=sm_spool) + mock_client.update_spool = AsyncMock(return_value=None) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/scale/update-spool-weight", + json={"spool_id": 99, "weight_grams": 500}, + ) + + assert resp.status_code == 502 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_local_mode_unchanged(self, async_client: AsyncClient, spool_factory): + """When Spoolman is NOT enabled, local DB update still works.""" + spool = await spool_factory(label_weight=1000, core_weight=250, weight_used=0) + + resp = await async_client.post( + f"{API}/scale/update-spool-weight", + json={"spool_id": spool.id, "weight_grams": 750}, + ) + + assert resp.status_code == 200 + assert resp.json()["weight_used"] == 500 + + +class TestTagScannedSpoolmanFallback: + """nfc/tag-scanned falls back to Spoolman when local DB has no match.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_fallback_on_local_miss(self, async_client: AsyncClient, spoolman_settings): + raw_spool = { + "id": 5, + "filament": { + "material": "PETG", + "name": "PETG Basic", + "color_hex": "00FF00", + "weight": 1000, + "vendor": {"name": "Polymaker"}, + }, + "used_weight": 100.0, + "archived": False, + "registered": "2024-01-01T00:00:00+00:00", + "extra": {"tag": '"DEADBEEF12345678"'}, + } + mock_client = _mock_spoolman_client() + mock_client.get_spools = AsyncMock(return_value=[raw_spool]) + mock_client.find_spool_by_tag = AsyncMock(return_value=raw_spool) + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.api.routes.spoolbuddy.get_spool_by_tag", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/tag-scanned", + json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["matched"] is True + assert data["spool_id"] == 5 + mock_ws.broadcast.assert_called_once() + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_tag_matched" + assert msg["spool"]["id"] == 5 + assert msg["spool"]["material"] == "PETG" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_fallback_unknown_when_no_spoolman_match(self, async_client: AsyncClient, spoolman_settings): + """Unknown tag broadcast when both local DB and Spoolman miss.""" + mock_client = _mock_spoolman_client() + mock_client.get_spools = AsyncMock(return_value=[]) + mock_client.find_spool_by_tag = AsyncMock(return_value=None) + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.api.routes.spoolbuddy.get_spool_by_tag", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/tag-scanned", + json={"device_id": "sb-1", "tag_uid": "UNKNOWN0000000FF"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["matched"] is False + assert data["spool_id"] is None + mock_ws.broadcast.assert_called_once() + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_unknown_tag" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_malformed_spoolman_data_degrades_gracefully(self, async_client: AsyncClient, spoolman_settings): + """ValueError from _map_spoolman_spool (e.g. spool_id=0) must return matched=False without broadcasting unknown_tag.""" + bad_spool = { + "id": 0, # _map_spoolman_spool raises ValueError for id <= 0 + "filament": {"material": "PLA", "name": "PLA Basic", "color_hex": "FF0000", "weight": 1000}, + "used_weight": 0.0, + "archived": False, + "registered": "2024-01-01T00:00:00Z", + "extra": {"tag": '"DEADBEEF12345678"'}, + } + mock_client = _mock_spoolman_client() + mock_client.find_spool_by_tag = AsyncMock(return_value=bad_spool) + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.api.routes.spoolbuddy.get_spool_by_tag", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/tag-scanned", + json={"device_id": "sb-1", "tag_uid": "DEADBEEF12345678"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["matched"] is False + assert data["spool_id"] is None + # No broadcast: UI must not get a spurious unknown_tag event on Spoolman data errors + mock_ws.broadcast.assert_not_called() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_local_match_skips_spoolman(self, async_client: AsyncClient, spool_factory): + """When local DB matches, Spoolman is never queried.""" + spool = await spool_factory(tag_uid="AABB1122", material="PLA") + mock_spool = MagicMock() + mock_spool.id = spool.id + mock_spool.material = spool.material + mock_spool.subtype = spool.subtype + mock_spool.color_name = spool.color_name + mock_spool.rgba = spool.rgba + mock_spool.brand = spool.brand + mock_spool.label_weight = spool.label_weight + mock_spool.core_weight = spool.core_weight + mock_spool.weight_used = spool.weight_used + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.api.routes.spoolbuddy.get_spool_by_tag", + new_callable=AsyncMock, + return_value=mock_spool, + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/tag-scanned", + json={"device_id": "sb-1", "tag_uid": "AABB1122"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["matched"] is True + assert data["spool_id"] == spool.id + + +# ============================================================================ +# NFC write-tag / write-result — Spoolman-aware +# ============================================================================ + + +def _full_spoolman_spool(spool_id: int) -> dict: + """Complete Spoolman spool dict sufficient for NDEF encoding.""" + return { + "id": spool_id, + "filament": { + "material": "PLA", + "name": "PLA Basic", + "color_hex": "FF0000", + "weight": 1000.0, + "spool_weight": 196.0, + "vendor": {"name": "Bambu Lab"}, + }, + "used_weight": 0.0, + "archived": False, + "registered": "2024-01-01T00:00:00Z", + } + + +class TestNfcWriteTagSpoolman: + """nfc/write-tag falls back to Spoolman when local DB has no matching spool.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_spool_queued_when_local_miss( + self, async_client: AsyncClient, device_factory, spoolman_settings + ): + """write-tag encodes NDEF from Spoolman data when spool not in local DB.""" + await device_factory(device_id="sb-write-sm") + sm_spool = _full_spoolman_spool(77) + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=sm_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-write-sm", "spool_id": 77}, + ) + + assert resp.status_code == 200 + assert resp.json()["status"] == "queued" + mock_client.get_spool.assert_called_once_with(77) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_data_origin_spoolman_stored_in_payload( + self, async_client: AsyncClient, device_factory, db_session, spoolman_settings + ): + """Pending write payload records data_origin=spoolman for Spoolman spools.""" + import json as _json + + device = await device_factory(device_id="sb-origin") + sm_spool = _full_spoolman_spool(88) + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=sm_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-origin", "spool_id": 88}, + ) + + await db_session.refresh(device) + payload = _json.loads(device.pending_write_payload) + assert payload["data_origin"] == "spoolman" + assert payload["spool_id"] == 88 + assert "ndef_data_hex" in payload + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_404_when_neither_local_nor_spoolman( + self, async_client: AsyncClient, device_factory, spoolman_settings + ): + """404 returned when spool is missing from both local DB and Spoolman.""" + await device_factory(device_id="sb-miss") + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 9999 not found")) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-miss", "spool_id": 9999}, + ) + + assert resp.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_local_spool_used_when_present(self, async_client: AsyncClient, device_factory, spool_factory): + """Local DB spool is encoded directly without contacting Spoolman.""" + await device_factory(device_id="sb-local-write") + spool = await spool_factory(material="PETG") + + resp = await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-local-write", "spool_id": spool.id}, + ) + + assert resp.status_code == 200 + assert resp.json()["status"] == "queued" + + +class TestNfcWriteResultSpoolman: + """nfc/write-result updates Spoolman extra.tag on success for Spoolman spools.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_success_updates_spoolman_extra_tag( + self, async_client: AsyncClient, device_factory, spoolman_settings + ): + """Successful write for a Spoolman spool calls merge_spool_extra with extra.tag.""" + import json as _json + + await device_factory( + device_id="sb-wr-sm", + pending_command="write_tag", + pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}), + ) + mock_client = _mock_spoolman_client() + mock_client.merge_spool_extra = AsyncMock(return_value={"id": 55}) + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-wr-sm", + "spool_id": 55, + "tag_uid": "AABBCCDD11223344", + "success": True, + }, + ) + + assert resp.status_code == 200 + mock_client.merge_spool_extra.assert_called_once_with(55, {"tag": '"AABBCCDD11223344"'}) + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_tag_written" + assert msg["tag_uid"] == "AABBCCDD11223344" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_failure_does_not_call_spoolman(self, async_client: AsyncClient, device_factory, spoolman_settings): + """Failed write never calls Spoolman update.""" + import json as _json + + await device_factory( + device_id="sb-wr-fail", + pending_command="write_tag", + pending_write_payload=_json.dumps({"spool_id": 66, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}), + ) + mock_client = _mock_spoolman_client() + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-wr-fail", + "spool_id": 66, + "tag_uid": "AABBCCDD11223344", + "success": False, + "message": "write timeout", + }, + ) + + assert resp.status_code == 200 + mock_client.update_spool.assert_not_called() + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_tag_write_failed" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_success_local_spool_writes_to_db( + self, async_client: AsyncClient, device_factory, spool_factory, db_session + ): + """Successful write for a local spool still updates local DB tag_uid.""" + import json as _json + + spool = await spool_factory() + await device_factory( + device_id="sb-wr-local", + pending_command="write_tag", + pending_write_payload=_json.dumps( + {"spool_id": spool.id, "ndef_data_hex": "deadbeef", "data_origin": "local"} + ), + ) + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-wr-local", + "spool_id": spool.id, + "tag_uid": "DEADBEEF12345678", + "success": True, + }, + ) + + assert resp.status_code == 200 + await db_session.refresh(spool) + assert spool.tag_uid == "DEADBEEF12345678" + assert spool.tag_type == "ntag" + + +# ============================================================================ +# Security fix tests — write-tag ValueError + write-result exception safety +# ============================================================================ + + +class TestNfcWriteTagSpoolmanSecurityFixes: + """Regression tests for security fixes in nfc/write-tag Spoolman path.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_invalid_spoolman_spool_id_returns_502( + self, async_client: AsyncClient, device_factory, spoolman_settings + ): + """Malformed Spoolman spool (invalid id=0) raises 502, not 404 — spool exists but is bad data.""" + await device_factory(device_id="sb-invalid-id") + # Spoolman returns spool with id=0 (invalid — caught by _map_spoolman_spool guard) + bad_spool = {**_full_spoolman_spool(1), "id": 0} + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=bad_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-invalid-id", "spool_id": 99}, + ) + + # 502: spool exists in Spoolman but its data is malformed — not a "not found" + assert resp.status_code == 502 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_oversized_label_weight_does_not_crash( + self, async_client: AsyncClient, device_factory, spoolman_settings + ): + """label_weight > 65535 from Spoolman must not crash with struct.error.""" + await device_factory(device_id="sb-overflow") + big_weight_spool = { + **_full_spoolman_spool(42), + "filament": {**_full_spoolman_spool(42)["filament"], "weight": 70000}, + } + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=big_weight_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-overflow", "spool_id": 42}, + ) + + assert resp.status_code == 200 + assert resp.json()["status"] == "queued" + + +class TestNfcWriteResultSpoolmanSecurityFixes: + """Regression tests for transaction safety in nfc/write-result Spoolman path.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_client_exception_still_clears_device_state( + self, async_client: AsyncClient, device_factory, db_session, spoolman_settings + ): + """If Spoolman client raises, device pending_command is still cleared in DB.""" + import json as _json + + device = await device_factory( + device_id="sb-exc-safe", + pending_command="write_tag", + pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}), + ) + mock_client = _mock_spoolman_client() + mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused")) + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-exc-safe", + "spool_id": 77, + "tag_uid": "AABBCCDD11223344", + "success": True, + }, + ) + + # 502: tag written to NFC but Spoolman link failed (not best-effort — caller must retry) + assert resp.status_code == 502 + # Device state must be cleared despite the exception (no spurious re-write) + await db_session.refresh(device) + assert device.pending_command is None + assert device.pending_write_payload is None + # Failure broadcast fires so the UI can show the error + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_tag_link_failed" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_spoolman_not_found_error_broadcasts_link_failed( + self, async_client: AsyncClient, device_factory, db_session, spoolman_settings + ): + """SpoolmanNotFoundError from merge_spool_extra must clear device state and broadcast link_failed.""" + import json as _json + + device = await device_factory( + device_id="sb-notfound", + pending_command="write_tag", + pending_write_payload=_json.dumps({"spool_id": 55, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}), + ) + mock_client = _mock_spoolman_client() + mock_client.merge_spool_extra = AsyncMock(side_effect=SpoolmanNotFoundError("Spool 55 not found")) + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-notfound", + "spool_id": 55, + "tag_uid": "AABBCCDD11223344", + "success": True, + }, + ) + + assert resp.status_code == 502 + await db_session.refresh(device) + assert device.pending_command is None + assert device.pending_write_payload is None + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_tag_link_failed" + assert msg["spool_id"] == 55 + + +class TestNfcWriteResultOrphanedSpool: + """nfc/write-result when the local spool was deleted between write-queue and write-result.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_local_spool_deleted_before_write_back(self, async_client: AsyncClient, device_factory, db_session): + """When local spool is deleted between write-queue and write-result, return linked=False and broadcast link_failed.""" + import json as _json + + device = await device_factory( + device_id="sb-orphan", + pending_command="write_tag", + pending_write_payload=_json.dumps( + { + "spool_id": 99999, # non-existent spool + "ndef_data_hex": "aabbccdd", + "data_origin": "local", + } + ), + ) + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={"device_id": device.device_id, "spool_id": 99999, "success": True, "tag_uid": "AABBCCDD"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["linked"] is False + + # pending command should be cleared + await db_session.refresh(device) + assert device.pending_command is None + + # broadcast should be spoolbuddy_tag_link_failed + broadcast_calls = mock_ws.broadcast.call_args_list + link_failed = [c[0][0] for c in broadcast_calls if c[0][0].get("type") == "spoolbuddy_tag_link_failed"] + assert len(link_failed) >= 1 + + +class TestNfcWriteResultInputValidation: + """Input validation and JSON safety for nfc/write-result.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_tag_uid_too_long_rejected(self, async_client: AsyncClient, device_factory): + """tag_uid longer than 32 chars must be rejected with 422.""" + import json as _json + + await device_factory( + device_id="sb-uid-long", + pending_command="write_tag", + pending_write_payload=_json.dumps({"spool_id": 1, "ndef_data_hex": "dead", "data_origin": "local"}), + ) + + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-uid-long", + "spool_id": 1, + "tag_uid": "A" * 65, + "success": True, + }, + ) + + assert resp.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_malformed_pending_payload_falls_back_to_local( + self, async_client: AsyncClient, device_factory, spool_factory, db_session + ): + """Corrupted pending_write_payload JSON falls back to local mode gracefully.""" + spool = await spool_factory() + await device_factory( + device_id="sb-corrupt-json", + pending_command="write_tag", + pending_write_payload="{not valid json!!!", + ) + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-corrupt-json", + "spool_id": spool.id, + "tag_uid": "DEADBEEF12345678", + "success": True, + }, + ) + + # Must return 200, not 500 + assert resp.status_code == 200 + # Falls back to local mode — tag written to DB + await db_session.refresh(spool) + assert spool.tag_uid == "DEADBEEF12345678" + + +# ============================================================================ +# B1: NFC write-tag warnings appear in response body +# ============================================================================ + + +class TestNfcWriteTagWarningsBody: + """B1: resp.json()['warnings'] is populated when Spoolman fields are absent.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_warnings_returned_for_missing_color_and_temp( + self, async_client: AsyncClient, device_factory, spoolman_settings + ): + """Both color_name=None and settings_extruder_temp=None produce 2 warnings.""" + await device_factory(device_id="sb-warn-b1") + # Spoolman spool with no color_name or nozzle temp + sparse_spool = { + "id": 99, + "filament": { + "material": "PLA", + "name": "PLA Basic", + "color_hex": "808080", + # color_name absent → None after mapping + # settings_extruder_temp absent → nozzle_temp_min=None + "weight": 1000.0, + "vendor": {"name": "Bambu Lab"}, + }, + "used_weight": 0.0, + "archived": False, + "registered": "2024-01-01T00:00:00Z", + } + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=sparse_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-warn-b1", "spool_id": 99}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert "warnings" in body, "Response should contain 'warnings' key when fields are absent" + warnings = body["warnings"] + assert len(warnings) >= 2, f"Expected at least 2 warnings for missing color_name + nozzle_temp, got: {warnings}" + # Confirm the specific fields are mentioned + warn_text = " ".join(warnings) + assert "color_name" in warn_text + assert "nozzle_temp" in warn_text + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_no_warnings_key_when_all_fields_present( + self, async_client: AsyncClient, device_factory, spoolman_settings + ): + """No 'warnings' key in response when all fields are populated.""" + await device_factory(device_id="sb-nowarn") + full_spool = _full_spoolman_spool(100) + # Add color_name and extruder temp + full_spool["filament"]["color_name"] = "Red" + full_spool["filament"]["settings_extruder_temp"] = 220 + mock_client = _mock_spoolman_client() + mock_client.get_spool = AsyncMock(return_value=full_spool) + + with ( + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + resp = await async_client.post( + f"{API}/nfc/write-tag", + json={"device_id": "sb-nowarn", "spool_id": 100}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert "warnings" not in body or body["warnings"] == [] + + +# ============================================================================ +# B5: Exception text scrubbed from WebSocket broadcast message +# ============================================================================ + + +class TestNfcWriteResultExceptionScrubbing: + """B5: Internal exception details must not appear in WebSocket 'message' field.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_exception_text_not_leaked_in_ws_message( + self, async_client: AsyncClient, device_factory, db_session, spoolman_settings + ): + """When Spoolman merge raises, WS message is generic; 'connection refused' absent.""" + import json as _json + + await device_factory( + device_id="sb-scrub-b5", + pending_command="write_tag", + pending_write_payload=_json.dumps({"spool_id": 77, "ndef_data_hex": "deadbeef", "data_origin": "spoolman"}), + ) + mock_client = _mock_spoolman_client() + mock_client.merge_spool_extra = AsyncMock(side_effect=Exception("connection refused to 192.168.1.1:7912")) + + with ( + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + f"{API}/nfc/write-result", + json={ + "device_id": "sb-scrub-b5", + "spool_id": 77, + "tag_uid": "AABBCCDD11223344", + "success": True, + }, + ) + + assert resp.status_code == 502 + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_tag_link_failed" + # Generic message — no internal exception details leaked + assert msg["message"] == "Spoolman link failed", f"Expected generic message but got: {msg['message']!r}" + assert "connection refused" not in str(msg), f"Exception text must not appear in WS message: {msg}" + assert "192.168.1" not in str(msg), f"Internal IP must not appear in WS message: {msg}" + + +# ============================================================================ +# _get_spoolman_client_or_none: graceful degradation on ValueError during reinit +# ============================================================================ + + +class TestSpoolmanClientOrNoneGraceful: + """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_returns_none_when_init_raises_value_error(self, async_client: AsyncClient, db_session): + """_get_spoolman_client_or_none returns None when init_spoolman_client raises ValueError, + so the device endpoint degrades gracefully instead of propagating a 500 error.""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value="http://spoolman.local:7912")) + await db_session.commit() + + with ( + patch("backend.app.api.routes._spoolman_helpers.assert_safe_spoolman_url"), + patch( + "backend.app.services.spoolman.get_spoolman_client", + AsyncMock(return_value=None), + ), + patch( + "backend.app.services.spoolman.init_spoolman_client", + AsyncMock(side_effect=ValueError("invalid URL")), + ), + patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws, + ): + mock_ws.broadcast = AsyncMock() + # nfc/tag-scanned calls _get_spoolman_client_or_none; with None returned it + # must broadcast unknown_tag (not raise 500 due to ValueError propagating). + resp = await async_client.post( + f"{API}/nfc/tag-scanned", + json={"device_id": "sb-vale", "tag_uid": "AABBCCDD"}, + ) + + # Must not be 500 — ValueError is caught and client returns None, degrading gracefully + assert resp.status_code == 200 + data = resp.json() + assert data["matched"] is False diff --git a/backend/tests/integration/test_spoolman_inventory_api.py b/backend/tests/integration/test_spoolman_inventory_api.py new file mode 100644 index 000000000..353655c2b --- /dev/null +++ b/backend/tests/integration/test_spoolman_inventory_api.py @@ -0,0 +1,1524 @@ +"""Integration tests for the Spoolman inventory proxy endpoints. + +These tests verify that /api/v1/spoolman/inventory/spools/* correctly +translates between Spoolman's data model and Bambuddy's InventorySpool format. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from httpx import AsyncClient + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +SAMPLE_SPOOLMAN_SPOOL = { + "id": 42, + "filament": { + "id": 7, + "name": "PLA Basic", + "material": "PLA", + "color_hex": "FF0000", + "weight": 1000, + "vendor": {"id": 3, "name": "Bambu Lab"}, + }, + "remaining_weight": 750.0, + "used_weight": 250.0, + "location": "Printer1 - AMS A1", + "comment": "test note", + "first_used": "2024-01-01T00:00:00+00:00", + "last_used": "2024-02-01T00:00:00+00:00", + "registered": "2024-01-01T00:00:00+00:00", + "archived": False, + "price": None, + "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'}, +} + + +@pytest.fixture +async def spoolman_settings(db_session): + """Create Spoolman settings in the database (enabled with URL).""" + from backend.app.models.settings import Settings + + enabled_setting = Settings(key="spoolman_enabled", value="true") + url_setting = Settings(key="spoolman_url", value="http://localhost:7912") + db_session.add(enabled_setting) + db_session.add(url_setting) + await db_session.commit() + return {"enabled": enabled_setting, "url": url_setting} + + +@pytest.fixture +def mock_spoolman_client(): + """Mock the Spoolman client with a sample spool.""" + mock_client = MagicMock() + mock_client.base_url = "http://localhost:7912" + mock_client.health_check = AsyncMock(return_value=True) + mock_client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL]) + mock_client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL) + mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL) + mock_client.delete_spool = AsyncMock(return_value=True) + mock_client.set_spool_archived = AsyncMock( + side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived} + ) + mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL) + mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL) + mock_client.find_or_create_filament = AsyncMock(return_value=7) + + with ( + patch( + "backend.app.api.routes.spoolman_inventory.get_spoolman_client", + AsyncMock(return_value=mock_client), + ), + patch( + "backend.app.api.routes.spoolman_inventory.init_spoolman_client", + AsyncMock(return_value=mock_client), + ), + ): + yield mock_client + + +class TestSpoolmanInventoryMapping: + """Tests for the Spoolman → InventorySpool data mapping.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_spools_returns_inventory_format( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """GET /spoolman/inventory/spools returns spools in InventorySpool format.""" + response = await async_client.get("/api/v1/spoolman/inventory/spools") + + assert response.status_code == 200 + spools = response.json() + assert isinstance(spools, list) + assert len(spools) == 1 + + spool = spools[0] + assert spool["id"] == 42 + assert spool["material"] == "PLA" + assert spool["subtype"] == "Basic" + assert spool["brand"] == "Bambu Lab" + assert spool["label_weight"] == 1000 + assert spool["weight_used"] == 250.0 + assert spool["note"] == "test note" + assert spool["data_origin"] == "spoolman" + assert spool["tag_type"] == "spoolman" + # RRGGBB + FF alpha + assert spool["rgba"] == "FF0000FF" + # Spoolman location mapped to storage_location + assert spool["storage_location"] == "Printer1 - AMS A1" + # RFID tag: 32-char → tray_uuid + assert spool["tray_uuid"] == "AABBCCDDEEFF0011AABBCCDDEEFF0011" + assert spool["tag_uid"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_single_spool( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """GET /spoolman/inventory/spools/{id} returns a single spool.""" + response = await async_client.get("/api/v1/spoolman/inventory/spools/42") + + assert response.status_code == 200 + spool = response.json() + assert spool["id"] == 42 + assert spool["material"] == "PLA" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_includes_archived_when_requested( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """GET /spoolman/inventory/spools?include_archived=true calls Spoolman with allow_archived.""" + await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true") + mock_spoolman_client.get_all_spools.assert_called_once_with(allow_archived=True) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archived_spool_has_archived_at( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """An archived Spoolman spool maps to archived_at != None.""" + archived_spool = { + **SAMPLE_SPOOLMAN_SPOOL, + "archived": True, + } + mock_spoolman_client.get_all_spools.return_value = [archived_spool] + + response = await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true") + spool = response.json()[0] + assert spool["archived_at"] is not None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_malformed_spool_skipped_in_list( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """A spool with an invalid id (e.g. 0) is silently skipped; others still appear.""" + bad_spool = {**SAMPLE_SPOOLMAN_SPOOL, "id": 0} + mock_spoolman_client.get_all_spools.return_value = [bad_spool, SAMPLE_SPOOLMAN_SPOOL] + + response = await async_client.get("/api/v1/spoolman/inventory/spools") + assert response.status_code == 200 + spools = response.json() + # bad_spool is dropped; the valid one survives + assert len(spools) == 1 + assert spools[0]["id"] == 42 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_spools_returns_503_when_spoolman_unavailable( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """GET /spoolman/inventory/spools returns 503 when Spoolman is unreachable (H10).""" + from backend.app.services.spoolman import SpoolmanUnavailableError + + mock_spoolman_client.get_all_spools.side_effect = SpoolmanUnavailableError("down") + + response = await async_client.get("/api/v1/spoolman/inventory/spools") + assert response.status_code == 503 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_tag_uid_16char_maps_correctly( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """A 16-char tag maps to tag_uid, not tray_uuid.""" + spool_with_short_tag = { + **SAMPLE_SPOOLMAN_SPOOL, + "extra": {"tag": '"AABBCCDDEEFF0011"'}, + } + mock_spoolman_client.get_all_spools.return_value = [spool_with_short_tag] + + response = await async_client.get("/api/v1/spoolman/inventory/spools") + spool = response.json()[0] + assert spool["tag_uid"] == "AABBCCDDEEFF0011" + assert spool["tray_uuid"] is None + + +class TestSpoolmanInventoryCRUD: + """Tests for create, update, delete, archive, restore operations.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_not_enabled_returns_400(self, async_client: AsyncClient): + """All endpoints return 400 when Spoolman is not enabled.""" + response = await async_client.get("/api/v1/spoolman/inventory/spools") + assert response.status_code == 400 + assert "not enabled" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_spool( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """POST /spoolman/inventory/spools creates a spool via Spoolman.""" + payload = { + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "rgba": "FF0000FF", + "label_weight": 1000, + "weight_used": 0, + } + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + + assert response.status_code == 200 + mock_spoolman_client.find_or_create_filament.assert_called_once() + mock_spoolman_client.create_spool.assert_called_once() + data = response.json() + assert data["material"] == "PLA" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_create_spools( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """POST /spoolman/inventory/spools/bulk creates multiple spools.""" + payload = { + "spool": {"material": "PETG", "label_weight": 1000, "weight_used": 0}, + "quantity": 3, + } + response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload) + + assert response.status_code == 200 + assert mock_spoolman_client.create_spool.call_count == 3 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_create_quantity_out_of_range_returns_422( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Bulk create quantity outside 1-50 is rejected with 422 (not silently clamped).""" + payload = { + "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0}, + "quantity": 999, + } + response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_create_quantity_zero_returns_422( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Bulk create quantity of 0 is rejected with 422.""" + payload = { + "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0}, + "quantity": 0, + } + response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_spool( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH /spoolman/inventory/spools/{id} updates a spool.""" + payload = {"note": "updated note", "weight_used": 100.0} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + + assert response.status_code == 200 + mock_spoolman_client.update_spool_full.assert_called_once() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_spool_not_found( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH returns 404 when Spoolman spool does not exist.""" + from backend.app.services.spoolman import SpoolmanNotFoundError + + mock_spoolman_client.get_spool.side_effect = SpoolmanNotFoundError("spool not found") + response = await async_client.patch("/api/v1/spoolman/inventory/spools/999", json={"note": "x"}) + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_spool( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """DELETE /spoolman/inventory/spools/{id} deletes a spool.""" + response = await async_client.delete("/api/v1/spoolman/inventory/spools/42") + + assert response.status_code == 200 + assert response.json()["status"] == "deleted" + mock_spoolman_client.delete_spool.assert_called_once_with(42) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_spool_failure( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """DELETE returns 503 when Spoolman is unreachable.""" + from backend.app.services.spoolman import SpoolmanUnavailableError + + mock_spoolman_client.delete_spool.side_effect = SpoolmanUnavailableError("unreachable") + response = await async_client.delete("/api/v1/spoolman/inventory/spools/42") + assert response.status_code == 503 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_spool_not_found( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """DELETE returns 404 when Spoolman reports the spool does not exist.""" + from backend.app.services.spoolman import SpoolmanNotFoundError + + mock_spoolman_client.delete_spool.side_effect = SpoolmanNotFoundError("gone") + response = await async_client.delete("/api/v1/spoolman/inventory/spools/42") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archive_spool_not_found( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """POST /archive returns 404 when Spoolman reports the spool does not exist.""" + from backend.app.services.spoolman import SpoolmanNotFoundError + + mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone") + response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_spool_not_found( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """POST /restore returns 404 when Spoolman reports the spool does not exist.""" + from backend.app.services.spoolman import SpoolmanNotFoundError + + mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone") + response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore") + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_archive_spool( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """POST /spoolman/inventory/spools/{id}/archive archives a spool.""" + response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive") + + assert response.status_code == 200 + mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=True) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_spool( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """POST /spoolman/inventory/spools/{id}/restore restores an archived spool.""" + response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore") + + assert response.status_code == 200 + mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=False) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_sync_weight( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH /spoolman/inventory/spools/{id}/weight updates remaining weight.""" + payload = {"weight_grams": 850.0} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json=payload) + + assert response.status_code == 200 + result = response.json() + assert result["status"] == "ok" + # remaining = 850 - 250 core = 600; weight_used = 1000 - 600 = 400 + assert result["weight_used"] == 400.0 + mock_spoolman_client.update_spool_full.assert_called_once_with(spool_id=42, remaining_weight=600.0) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_spool_returns_404_on_not_found( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH returns 404 when update_spool_full raises SpoolmanNotFoundError (I2).""" + from backend.app.services.spoolman import SpoolmanNotFoundError + + mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone") + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"}) + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_spool_returns_503_on_unavailable( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH returns 503 when update_spool_full raises SpoolmanUnavailableError (I2).""" + from backend.app.services.spoolman import SpoolmanUnavailableError + + mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down") + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"}) + assert response.status_code == 503 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_sync_weight_returns_404_on_not_found( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH /weight returns 404 when update_spool_full raises SpoolmanNotFoundError (I2).""" + from backend.app.services.spoolman import SpoolmanNotFoundError + + mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone") + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0}) + assert response.status_code == 404 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_sync_weight_returns_503_on_unavailable( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH /weight returns 503 when update_spool_full raises SpoolmanUnavailableError (I2).""" + from backend.app.services.spoolman import SpoolmanUnavailableError + + mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down") + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0}) + assert response.status_code == 503 + + +class TestSpoolmanInventoryCostPerKg: + """Tests for the two-step cost_per_kg create path (PT-C2).""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_spool_with_cost_per_kg_calls_price_update( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """POST with cost_per_kg calls update_spool_full with price= after creation.""" + from unittest.mock import AsyncMock + + mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL) + + payload = { + "material": "PLA", + "brand": "Bambu Lab", + "label_weight": 1000, + "cost_per_kg": 24.99, + } + resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert resp.status_code == 200 + # update_spool_full must have been called with price=24.99 + calls = [ + c + for c in mock_spoolman_client.update_spool_full.call_args_list + if c.kwargs.get("price") == 24.99 or (c.args and 24.99 in c.args) + ] + assert len(calls) >= 1 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_spool_without_cost_per_kg_skips_price_update( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """POST without cost_per_kg does not call update_spool_full.""" + from unittest.mock import AsyncMock + + mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL) + + payload = {"material": "PLA", "brand": "Bambu Lab", "label_weight": 1000} + resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert resp.status_code == 200 + mock_spoolman_client.update_spool_full.assert_not_called() + + +class TestSpoolmanInventoryInputValidation: + """Tests for input validation added as security hardening.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rejects_material_too_long( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """material longer than 64 chars is rejected with 422.""" + payload = {"material": "A" * 65, "label_weight": 1000, "weight_used": 0} + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rejects_note_too_long( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """note longer than 1000 chars is rejected with 422.""" + payload = { + "material": "PLA", + "label_weight": 1000, + "weight_used": 0, + "note": "x" * 1001, + } + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rejects_negative_weight_used( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Negative weight_used is rejected with 422.""" + payload = {"material": "PLA", "label_weight": 1000, "weight_used": -1.0} + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rejects_zero_label_weight( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """label_weight of 0 is rejected (minimum is 1).""" + payload = {"material": "PLA", "label_weight": 0, "weight_used": 0} + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rejects_invalid_rgba( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Non-hex rgba string is rejected with 422.""" + payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "GGGGGGFF"} + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_accepts_valid_6char_rgba( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """A valid 6-char hex rgba is accepted.""" + payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "FF0000"} + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_weight_update_rejects_negative_grams( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Negative weight_grams on weight sync endpoint is rejected with 422.""" + response = await async_client.patch( + "/api/v1/spoolman/inventory/spools/42/weight", + json={"weight_grams": -50.0}, + ) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rejects_tag_uid_too_long( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """tag_uid longer than 30 chars is rejected with 422 (NFC UID max 10 bytes = 20 hex chars, capped at 30).""" + payload = {"tag_uid": "A" * 65} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rejects_tray_uuid_too_long( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """tray_uuid longer than 32 chars is rejected with 422.""" + payload = {"tray_uuid": "B" * 65} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("uuid_len", [16, 31]) + async def test_update_rejects_tray_uuid_too_short( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + uuid_len: int, + ): + """tray_uuid shorter than 32 chars is rejected (min_length=max_length=32).""" + payload = {"tray_uuid": "A" * uuid_len} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rejects_rgba_nine_chars( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """rgba must be max 8 hex chars; 9-char value is rejected with 422.""" + payload = {"rgba": "FF0000FFA"} # 9 chars + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_tag_uid_below_min_length_rejected( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """tag_uid shorter than 8 hex chars is rejected with 422 (PT-I5).""" + payload = {"tag_uid": "AABBCC"} # 6 chars, below min_length=8 + resp = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert resp.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_invalid_spoolman_url_scheme_returns_400( + self, + async_client: AsyncClient, + db_session, + mock_spoolman_client, + ): + """A spoolman_url with a non-http(s) scheme is rejected.""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value="ftp://evil.internal/")) + await db_session.commit() + + response = await async_client.get("/api/v1/spoolman/inventory/spools") + assert response.status_code == 400 + assert "http" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "evil_url", + [ + "file:///etc/passwd", + "gopher://127.0.0.1:70/", + "dict://internal.corp/", + "http://169.254.169.254/latest/meta-data/", # AWS IMDS (link-local) + "http://[::1]:7912/", # IPv6 loopback + "http://0.0.0.0/", # unspecified + "javascript:alert(1)", + "http://224.0.0.1/", # IPv4 multicast + "http://[ff02::1]/", # IPv6 multicast + "http://127.1.2.3/", # 127.x.x.x loopback range + "http://[::ffff:169.254.169.254]/", # IPv4-mapped IPv6 IMDS bypass + ], + ) + async def test_ssrf_blocked_schemes_and_addresses( + self, + async_client: AsyncClient, + db_session, + mock_spoolman_client, + evil_url: str, + ): + """SSRF: any Spoolman URL that is not http(s) must be rejected with 400.""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value=evil_url)) + await db_session.commit() + + response = await async_client.get("/api/v1/spoolman/inventory/spools") + assert response.status_code == 400, ( + f"Expected 400 for SSRF URL {evil_url!r} but got {response.status_code}: {response.json()}" + ) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rejects_storage_location_too_long( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """storage_location longer than 255 chars is rejected with 422.""" + payload = { + "material": "PLA", + "label_weight": 1000, + "weight_used": 0, + "storage_location": "x" * 256, + } + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_rejects_storage_location_too_long( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """storage_location longer than 255 chars on PATCH is rejected with 422.""" + payload = {"storage_location": "y" * 256} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 422 + + +class TestStorageLocationPassthrough: + """Tests that storage_location is correctly passed to and from Spoolman.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_spools_maps_spoolman_location_to_storage_location( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Spoolman's location field is exposed as storage_location in the response.""" + response = await async_client.get("/api/v1/spoolman/inventory/spools") + spool = response.json()[0] + assert spool["storage_location"] == "Printer1 - AMS A1" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_spools_null_location_gives_null_storage_location( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """A Spoolman spool with no location gives null storage_location.""" + spool_no_loc = {**SAMPLE_SPOOLMAN_SPOOL, "location": None} + mock_spoolman_client.get_all_spools.return_value = [spool_no_loc] + response = await async_client.get("/api/v1/spoolman/inventory/spools") + spool = response.json()[0] + assert spool["storage_location"] is None + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_passes_storage_location_to_spoolman( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """storage_location is forwarded as location when creating a Spoolman spool.""" + payload = { + "material": "PLA", + "label_weight": 1000, + "weight_used": 0, + "storage_location": "Shelf B", + } + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 200 + mock_spoolman_client.create_spool.assert_called_once() + _, kwargs = mock_spoolman_client.create_spool.call_args + assert kwargs.get("location") == "Shelf B" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_passes_storage_location_to_spoolman( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """storage_location is forwarded as location when updating a Spoolman spool.""" + payload = {"storage_location": "Drawer 3"} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 200 + mock_spoolman_client.update_spool_full.assert_called_once() + _, kwargs = mock_spoolman_client.update_spool_full.call_args + assert kwargs.get("location") == "Drawer 3" + assert kwargs.get("clear_location") is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_clears_storage_location_when_null_sent( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Explicitly sending null storage_location clears the Spoolman location.""" + payload = {"storage_location": None} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 200 + _, kwargs = mock_spoolman_client.update_spool_full.call_args + assert kwargs.get("clear_location") is True + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_clears_storage_location_when_empty_string_sent( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """Sending an empty string for storage_location also clears the Spoolman location.""" + payload = {"storage_location": ""} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 200 + _, kwargs = mock_spoolman_client.update_spool_full.call_args + assert kwargs.get("clear_location") is True + + +class TestColorNamePassthrough: + """color_name is forwarded to find_or_create_filament on create and update (B6 / T5).""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_passes_color_name_to_filament( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """color_name from the create payload is forwarded to find_or_create_filament.""" + payload = { + "material": "PLA", + "label_weight": 1000, + "weight_used": 0, + "color_name": "Bambu Green", + } + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 200 + mock_spoolman_client.find_or_create_filament.assert_called_once() + _, kwargs = mock_spoolman_client.find_or_create_filament.call_args + assert kwargs.get("color_name") == "Bambu Green" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_passes_color_name_to_filament( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """color_name from the update payload is forwarded to find_or_create_filament.""" + payload = {"color_name": "Jade White"} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 200 + mock_spoolman_client.find_or_create_filament.assert_called_once() + _, kwargs = mock_spoolman_client.find_or_create_filament.call_args + assert kwargs.get("color_name") == "Jade White" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_omits_color_name_when_not_provided( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """When color_name is not in the PATCH payload, the existing filament color_name is used.""" + payload = {"note": "no color_name here"} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == 200 + _, kwargs = mock_spoolman_client.find_or_create_filament.call_args + # color_name falls back to current filament's color_name (which is None in test fixture) + assert kwargs.get("color_name") is None + + +class TestSpoolmanInventoryAuth: + """Write/delete endpoints require INVENTORY_UPDATE when auth is enabled.""" + + @pytest.fixture + async def auth_and_spoolman_settings(self, db_session): + """Enable both Spoolman and auth.""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value="http://localhost:7912")) + db_session.add(Settings(key="auth_enabled", value="true")) + await db_session.commit() + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "method,path,payload", + [ + ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}), + ( + "POST", + "/api/v1/spoolman/inventory/spools/bulk", + {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1}, + ), + ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}), + ("DELETE", "/api/v1/spoolman/inventory/spools/42", None), + ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None), + ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None), + ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}), + ], + ) + async def test_write_endpoints_require_auth( + self, + async_client: AsyncClient, + auth_and_spoolman_settings, + method: str, + path: str, + payload: dict | None, + ): + """All write/delete endpoints return 401 when auth is enabled and no token is provided.""" + response = await async_client.request(method, path, json=payload) + assert response.status_code == 401, ( + f"{method} {path} should require auth but got {response.status_code}: {response.json()}" + ) + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "method,path", + [ + ("GET", "/api/v1/spoolman/inventory/spools"), + ("GET", "/api/v1/spoolman/inventory/spools/42"), + ], + ) + async def test_read_endpoints_require_auth( + self, + async_client: AsyncClient, + auth_and_spoolman_settings, + method: str, + path: str, + ): + """Read endpoints also require auth when auth is enabled.""" + response = await async_client.request(method, path) + assert response.status_code == 401, ( + f"{method} {path} should require auth but got {response.status_code}: {response.json()}" + ) + + @pytest.fixture + async def viewer_token(self, db_session): + """Create a Viewer-group user (INVENTORY_READ only, no INVENTORY_UPDATE).""" + from sqlalchemy import select + + from backend.app.core.auth import create_access_token, get_password_hash + from backend.app.models.group import Group + from backend.app.models.settings import Settings + from backend.app.models.user import User + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value="http://localhost:7912")) + db_session.add(Settings(key="auth_enabled", value="true")) + await db_session.commit() + + viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one() + viewer = User( + username="sm_inv_viewer", + password_hash=get_password_hash("pw"), + is_active=True, + ) + viewer.groups.append(viewer_group) + db_session.add(viewer) + await db_session.commit() + return create_access_token(data={"sub": viewer.username}) + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "method,path,payload", + [ + ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}), + ( + "POST", + "/api/v1/spoolman/inventory/spools/bulk", + {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1}, + ), + ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}), + ("DELETE", "/api/v1/spoolman/inventory/spools/42", None), + ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None), + ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None), + ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}), + ], + ) + async def test_write_endpoints_return_403_for_viewer( + self, + async_client: AsyncClient, + viewer_token, + method: str, + path: str, + payload: dict | None, + ): + """Viewer-group users (INVENTORY_READ, no INVENTORY_UPDATE) get 403 on write endpoints.""" + response = await async_client.request( + method, + path, + json=payload, + headers={"Authorization": f"Bearer {viewer_token}"}, + ) + assert response.status_code == 403, ( + f"{method} {path} should return 403 for read-only user but got {response.status_code}: {response.json()}" + ) + # Error body must mention the permission string so a "banned-user middleware" + # regression (generic 403 with no permission context) doesn't pass silently. + detail = response.json().get("detail", "") + assert "inventory:update" in detail, f"Expected 'inventory:update' in 403 detail but got: {detail!r}" + + +# --------------------------------------------------------------------------- +# Additional regression tests for second-round review items +# --------------------------------------------------------------------------- + + +class TestSpoolmanInventorySecurityExtras: + """Additional security/validation tests added in second review round.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_rejects_double_hash_rgba( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """SEC-3: rgba like '##FF0000' (double hash) must be rejected with 422.""" + payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "##FF0000"} + response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload) + assert response.status_code == 422 + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize("spool_id", [0, -1]) + async def test_path_param_non_positive_spool_id_returns_422( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + spool_id: int, + ): + """SEC-5: /spools/0 and /spools/-1 must be rejected with 422 (Path gt=0).""" + response = await async_client.get(f"/api/v1/spoolman/inventory/spools/{spool_id}") + assert response.status_code == 422, f"Expected 422 for spool_id={spool_id} but got {response.status_code}" + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "tag_uid,expected_status", + [ + ("A" * 30, 200), # exactly at NFC UID cap — valid + ("DEADBEEF12345678", 200), # 16-char backward compat — valid + ("A" * 31, 422), # one over limit — rejected by Pydantic max_length=30 + ("A" * 32, 422), # tray_uuid-length value rejected in tag_uid field + ], + ) + async def test_tag_uid_length_boundary( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + tag_uid: str, + expected_status: int, + ): + """tag_uid boundary — 30 chars valid (NFC UID max), 31+ rejected.""" + payload = {"tag_uid": tag_uid} + response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload) + assert response.status_code == expected_status, ( + f"tag_uid len={len(tag_uid)}: expected {expected_status} but got {response.status_code}" + ) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_create_partial_failure_returns_207( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """I9: bulk create with quantity=3 where middle call fails → 207 Multi-Status.""" + results = [SAMPLE_SPOOLMAN_SPOOL, None, SAMPLE_SPOOLMAN_SPOOL] + mock_spoolman_client.create_spool.side_effect = results + + payload = { + "spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, + "quantity": 3, + } + response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload) + assert response.status_code == 207, ( + f"Expected 207 Multi-Status for partial failure but got {response.status_code}" + ) + body = response.json() + assert isinstance(body, dict) + assert body["requested_count"] == 3 + assert body["failed_count"] == 1 + assert len(body["created"]) == 2 + + +class TestTagClearPreservesExtraKeys: + """Regression test: clearing tag_uid must not wipe unrelated Spoolman extra fields.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_tag_clear_preserves_custom_extra_key( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """PATCH tag_uid='' must preserve unrelated keys in Spoolman extra dict.""" + spool_with_extra = { + **SAMPLE_SPOOLMAN_SPOOL, + "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"', "custom_key": "keep_me"}, + } + mock_spoolman_client.get_spool = AsyncMock(return_value=spool_with_extra) + mock_spoolman_client.update_spool_full = AsyncMock(return_value=spool_with_extra) + + response = await async_client.patch( + "/api/v1/spoolman/inventory/spools/42", + json={"tag_uid": None}, + ) + assert response.status_code == 200 + + mock_spoolman_client.update_spool_full.assert_called_once() + _, kwargs = mock_spoolman_client.update_spool_full.call_args + sent_extra = kwargs.get("extra") + assert sent_extra is not None, "extra must be sent when tag is cleared" + assert "tag" not in sent_extra, "tag key must be removed when tag_uid is cleared" + assert sent_extra.get("custom_key") == "keep_me", "unrelated extra keys must survive" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_tag_clear_refetches_spool_inside_lock( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """B7: tag-clear does a fresh get_spool() re-fetch inside the lock, not the stale one. + + Simulates a write that changes extra between the initial get_spool (used for + other field resolution) and the lock acquisition. The extra sent to + update_spool_full must come from the second (in-lock) fetch, not the first. + """ + stale_extra = {"tag": '"AABBCCDD"', "custom_key": "stale_value"} + fresh_extra = {"tag": '"AABBCCDD"', "custom_key": "fresh_value"} + + stale_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": stale_extra} + fresh_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": fresh_extra} + + # First call returns stale; second call (inside lock) returns fresh + mock_spoolman_client.get_spool = AsyncMock(side_effect=[stale_spool, fresh_spool]) + mock_spoolman_client.update_spool_full = AsyncMock(return_value=fresh_spool) + + response = await async_client.patch( + "/api/v1/spoolman/inventory/spools/42", + json={"tag_uid": None, "tray_uuid": None}, + ) + assert response.status_code == 200 + + # get_spool called twice: once for field resolution, once for fresh extra fetch + assert mock_spoolman_client.get_spool.call_count == 2 + + _, kwargs = mock_spoolman_client.update_spool_full.call_args + sent_extra = kwargs.get("extra") + assert sent_extra is not None + assert "tag" not in sent_extra + # custom_key must come from the fresh re-fetch, not the stale first fetch + assert sent_extra.get("custom_key") == "fresh_value" + + +class TestSpoolmanInventorySSRFSpoolBuddyPath: + """SSRF tests for _get_spoolman_client_or_none (nfc/* and scale/ endpoints).""" + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "evil_url", + [ + "file:///etc/passwd", + "http://169.254.169.254/latest/meta-data/", # AWS IMDS + "http://[::1]:7912/", # IPv6 loopback + "http://0.0.0.0/", # unspecified + "http://10.0.0.1/", # RFC-1918 private + "http://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS bypass + ], + ) + async def test_nfc_tag_scanned_with_ssrf_url_ignores_spoolman( + self, + async_client: AsyncClient, + db_session, + evil_url: str, + ): + """SSRF: _get_spoolman_client_or_none silently disables Spoolman for unsafe URLs + on the SpoolBuddy NFC path (tag-scanned broadcasts unknown_tag, not 400).""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value=evil_url)) + await db_session.commit() + + from unittest.mock import AsyncMock, patch + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + "/api/v1/spoolbuddy/nfc/tag-scanned", + json={"device_id": "sb-ssrf", "tag_uid": "AABBCCDD"}, + ) + + # Must not crash or proxy the SSRF URL — unknown_tag is the safe degraded response + assert resp.status_code == 200 + if mock_ws.broadcast.called: + msg = mock_ws.broadcast.call_args[0][0] + assert msg["type"] == "spoolbuddy_unknown_tag" + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "evil_url", + [ + "http://169.254.169.254/latest/meta-data/", # AWS IMDS + "http://10.0.0.1/", # RFC-1918 private + "http://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS bypass + ], + ) + async def test_nfc_write_result_with_ssrf_url_degrades_gracefully( + self, + async_client: AsyncClient, + db_session, + evil_url: str, + ): + """SSRF: write-result with unsafe Spoolman URL must not proxy to the evil host. + + write-result calls Spoolman to write-back the tag UID when data_origin='spoolman'. + With an SSRF URL, _get_spoolman_client_or_none returns None so the call is skipped + and the route returns 502 (tag written but link not persisted — not a server crash). + """ + import json as _json + + from backend.app.models.settings import Settings + from backend.app.models.spoolbuddy_device import SpoolBuddyDevice + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value=evil_url)) + # Register the device so the route doesn't 404 before reaching the SSRF guard. + db_session.add( + SpoolBuddyDevice( + device_id="sb-ssrf-wr", + hostname="sb-ssrf-wr.local", + ip_address="127.0.0.1", + pending_command="write_tag", + pending_write_payload=_json.dumps({"spool_id": 99, "ndef_data_hex": "DEAD", "data_origin": "spoolman"}), + ) + ) + await db_session.commit() + + from unittest.mock import AsyncMock, patch + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + "/api/v1/spoolbuddy/nfc/write-result", + json={ + "device_id": "sb-ssrf-wr", + "spool_id": 99, + "tag_uid": "AABBCCDD", + "success": True, + }, + ) + + # 502 = tag written to NFC but Spoolman link not persisted (SSRF guard blocked it). + # Must not be 500 (crash) and must not have proxied to the evil host. + assert resp.status_code == 502 + + @pytest.mark.asyncio + @pytest.mark.integration + @pytest.mark.parametrize( + "evil_url", + [ + "http://169.254.169.254/latest/meta-data/", # AWS IMDS + "http://10.0.0.1/", # RFC-1918 private + ], + ) + async def test_scale_update_weight_with_ssrf_url_degrades_gracefully( + self, + async_client: AsyncClient, + db_session, + evil_url: str, + ): + """SSRF: scale weight update with unsafe Spoolman URL must not proxy to the evil host.""" + from backend.app.models.settings import Settings + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(Settings(key="spoolman_url", value=evil_url)) + await db_session.commit() + + from unittest.mock import AsyncMock, patch + + with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws: + mock_ws.broadcast = AsyncMock() + resp = await async_client.post( + "/api/v1/spoolbuddy/scale/update-spool-weight", + json={"device_id": "sb-ssrf-scale", "spool_id": 1, "weight_grams": 500.0}, + ) + + # Must not crash or proxy to an SSRF host + assert resp.status_code in (200, 404, 422) + + +class TestMergeSpoolExtraPreservesKeys: + """Unit-level test for merge_spool_extra key preservation (via mocked Spoolman).""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_merge_preserves_unrelated_extra_keys( + self, + async_client: AsyncClient, + spoolman_settings, + mock_spoolman_client, + ): + """merge_spool_extra must deep-merge rather than overwrite the extra dict. + + Seed extra={"custom_key": "keep_me", "tag": "old"}. + After merging {"tag": "new"}, the PATCH payload must still contain custom_key. + """ + from unittest.mock import AsyncMock, patch + + existing_spool = { + **SAMPLE_SPOOLMAN_SPOOL, + "extra": {"custom_key": "keep_me", "tag": '"old"'}, + } + updated_spool = {**existing_spool, "extra": {"custom_key": "keep_me", "tag": '"new"'}} + + mock_client = mock_spoolman_client + mock_client.get_spool = AsyncMock(return_value=existing_spool) + mock_client.update_spool_full = AsyncMock(return_value=updated_spool) + + # Call merge_spool_extra directly through the service + from backend.app.services.spoolman import SpoolmanClient + + client = SpoolmanClient.__new__(SpoolmanClient) + client.base_url = "http://localhost:7912" + client.api_url = "http://localhost:7912/api/v1" + client._extra_locks = {} + + async def _mock_get(spool_id): + return existing_spool + + async def _mock_update(spool_id, **kwargs): + # Capture what was actually sent + _mock_update.captured_extra = kwargs.get("extra") + return updated_spool + + _mock_update.captured_extra = None + client.get_spool = _mock_get + client.update_spool_full = _mock_update + + result = await client.merge_spool_extra(42, {"tag": '"new"'}) + + # The merged extra must include the unrelated key + assert _mock_update.captured_extra is not None + assert _mock_update.captured_extra.get("custom_key") == "keep_me" + assert _mock_update.captured_extra.get("tag") == '"new"' + assert result is not None + + +class TestGetClientValueError: + """Test the ValueError branch in _get_client when init_spoolman_client fails (Gap 5).""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_returns_400_when_init_spoolman_client_raises_value_error( + self, async_client: AsyncClient, spoolman_settings + ): + """If init_spoolman_client raises ValueError after SSRF check passes, return HTTP 400.""" + with ( + patch( + "backend.app.api.routes.spoolman_inventory.get_spoolman_client", + AsyncMock(return_value=None), + ), + patch( + "backend.app.api.routes.spoolman_inventory.init_spoolman_client", + AsyncMock(side_effect=ValueError("unsupported scheme")), + ), + ): + resp = await async_client.get("/api/v1/spoolman/inventory/spools") + assert resp.status_code == 400 + assert "unsupported scheme" in resp.json()["detail"] + + +class TestBulkCreateWithPriceFailure: + """Test that bulk create succeeds even when price update fails (Gap 6).""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_bulk_create_succeeds_when_price_update_fails( + self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client + ): + """Bulk create returns 200 even if update_spool_full (price) raises SpoolmanUnavailableError.""" + from backend.app.services.spoolman import SpoolmanUnavailableError + + mock_spoolman_client.update_spool_full = AsyncMock(side_effect=SpoolmanUnavailableError("price server down")) + mock_spoolman_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL) + + payload = { + "spool": { + "material": "PLA", + "brand": "Bambu Lab", + "label_weight": 1000, + "cost_per_kg": 19.99, + }, + "quantity": 2, + } + resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload) + # Price update failure must not abort the bulk create + assert resp.status_code in (200, 207) + # Both spools must have been created + assert mock_spoolman_client.create_spool.call_count == 2 + # Price update was attempted for each + assert mock_spoolman_client.update_spool_full.call_count == 2 diff --git a/backend/tests/unit/services/test_spoolman_service.py b/backend/tests/unit/services/test_spoolman_service.py index ffcc66766..ffa45604d 100644 --- a/backend/tests/unit/services/test_spoolman_service.py +++ b/backend/tests/unit/services/test_spoolman_service.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from backend.app.services.spoolman import AMSTray, SpoolmanClient +from backend.app.services.spoolman import AMSTray, SpoolmanClient, init_spoolman_client class TestIsBambuLabSpool: @@ -444,3 +444,50 @@ class TestSpoolmanClient: mock_close.assert_not_called() # Should sleep once (after first failed attempt) assert mock_sleep.call_count == 1 + + +# --------------------------------------------------------------------------- +# init_spoolman_client — SSRF guard (B4 / T3) +# --------------------------------------------------------------------------- + + +class TestInitSpoolmanClientSSRFGuard: + """init_spoolman_client must reject unsafe URLs before creating a client.""" + + @pytest.mark.asyncio + async def test_private_ip_raises_value_error(self): + with pytest.raises(ValueError, match="private|loopback|link-local|multicast|unspecified"): + await init_spoolman_client("http://10.0.0.1/") + + @pytest.mark.asyncio + async def test_link_local_raises_value_error(self): + with pytest.raises(ValueError): + await init_spoolman_client("http://169.254.169.254/latest/meta-data/") + + @pytest.mark.asyncio + async def test_loopback_ip_raises_value_error(self): + with pytest.raises(ValueError): + await init_spoolman_client("http://127.0.0.1:7912/") + + @pytest.mark.asyncio + async def test_localhost_hostname_is_allowed(self): + # localhost (hostname, not bare IP) is a supported topology for same-host Spoolman + mock_instance = AsyncMock() + with ( + patch("backend.app.services.spoolman._spoolman_client", None), + patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls, + ): + client = await init_spoolman_client("http://localhost:7912/") + mock_cls.assert_called_once_with("http://localhost:7912/") + assert client is mock_instance + + @pytest.mark.asyncio + async def test_public_url_is_allowed(self): + mock_instance = AsyncMock() + with ( + patch("backend.app.services.spoolman._spoolman_client", None), + patch("backend.app.services.spoolman.SpoolmanClient", return_value=mock_instance) as mock_cls, + ): + client = await init_spoolman_client("http://spoolman.example.com:7912/") + mock_cls.assert_called_once_with("http://spoolman.example.com:7912/") + assert client is mock_instance diff --git a/backend/tests/unit/services/test_spoolman_tracking.py b/backend/tests/unit/services/test_spoolman_tracking.py index 52b5a5efd..610176e6c 100644 --- a/backend/tests/unit/services/test_spoolman_tracking.py +++ b/backend/tests/unit/services/test_spoolman_tracking.py @@ -19,15 +19,15 @@ from backend.app.services.spoolman_tracking import ( class TestResolveSpoolTag: """Tests for _resolve_spool_tag().""" - def test_prefers_tray_uuid(self): + def test_prefers_tray_uuid_over_tag_uid(self): tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": "DEADBEEF"} assert _resolve_spool_tag(tray) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" - def test_falls_back_to_tag_uid(self): + def test_falls_back_to_tag_uid_when_no_uuid(self): tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"} assert _resolve_spool_tag(tray) == "DEADBEEF" - def test_skips_zero_uuid(self): + def test_falls_back_to_tag_uid_when_uuid_zero(self): tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"} assert _resolve_spool_tag(tray) == "DEADBEEF" @@ -45,6 +45,10 @@ class TestResolveSpoolTag: # global_tray_id 5 -> ams_id 1, tray_id 1 assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001" + def test_prefers_tray_uuid_over_fallback_when_non_zero(self): + tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": ""} + assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" + def test_empty_both(self): tray = {"tray_uuid": "", "tag_uid": ""} assert _resolve_spool_tag(tray) == "" diff --git a/backend/tests/unit/test_opentag3d.py b/backend/tests/unit/test_opentag3d.py index d9f2a0bbb..a36d3a187 100644 --- a/backend/tests/unit/test_opentag3d.py +++ b/backend/tests/unit/test_opentag3d.py @@ -7,7 +7,9 @@ from backend.app.services.opentag3d import ( OPENTAG3D_MIME_TYPE, PAYLOAD_SIZE, _build_payload, + _build_payload_from_dict, encode_opentag3d, + encode_opentag3d_from_mapped, ) @@ -140,3 +142,180 @@ class TestEncodeOpentag3d: data = encode_opentag3d(_make_spool()) ntag213_capacity = 36 * 4 # 144 bytes assert len(data) <= ntag213_capacity + + +# --------------------------------------------------------------------------- +# _build_payload_from_dict / encode_opentag3d_from_mapped +# --------------------------------------------------------------------------- + +MINIMAL_MAPPED = { + "material": "PLA", + "subtype": "Basic", + "brand": "Bambu Lab", + "color_name": None, + "rgba": "FF0000FF", + "label_weight": 1000, + "nozzle_temp_min": None, +} + + +class TestBuildPayloadFromDict: + def test_payload_is_102_bytes(self): + assert len(_build_payload_from_dict(MINIMAL_MAPPED)) == PAYLOAD_SIZE + + def test_material_encoded(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "material": "PETG"}) + assert payload[0x02:0x07].decode("utf-8") == "PETG " + + def test_subtype_encoded(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "subtype": "Silk"}) + assert payload[0x07:0x0C].decode("utf-8") == "Silk " + + def test_brand_encoded(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "brand": "Polymaker"}) + assert payload[0x1B:0x2B].decode("utf-8") == "Polymaker " + + def test_rgba_encoded(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "rgba": "00FF00FF"}) + assert payload[0x4B:0x4F] == bytes([0x00, 0xFF, 0x00, 0xFF]) + + def test_label_weight_encoded(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 750}) + weight = struct.unpack_from(">H", payload, 0x5E)[0] + assert weight == 750 + + def test_none_color_name_zero_filled(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "color_name": None}) + assert payload[0x2B:0x4B] == b" " + + def test_missing_keys_produce_safe_defaults(self): + payload = _build_payload_from_dict({}) + assert len(payload) == PAYLOAD_SIZE + assert payload[0x02:0x07] == b" " + weight = struct.unpack_from(">H", payload, 0x5E)[0] + assert weight == 0 + + def test_label_weight_overflow_clamped_to_65535(self): + """label_weight > 65535 must not raise struct.error (uint16 overflow).""" + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 70000}) + weight = struct.unpack_from(">H", payload, 0x5E)[0] + assert weight == 65535 + + def test_label_weight_negative_clamped_to_zero(self): + """Negative label_weight must be clamped to 0, not raise struct.error.""" + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": -1}) + weight = struct.unpack_from(">H", payload, 0x5E)[0] + assert weight == 0 + + def test_label_weight_at_uint16_max_accepted(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "label_weight": 65535}) + weight = struct.unpack_from(">H", payload, 0x5E)[0] + assert weight == 65535 + + def test_nozzle_temp_float_does_not_crash(self): + """nozzle_temp_min as float (e.g. 220.5) must not raise TypeError.""" + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": 220.5}) + assert payload[0x60] == 44 # int(220.5 // 5) = 44 + + def test_nozzle_temp_overflow_clamped_to_255(self): + """nozzle_temp_min causing byte > 255 must be clamped, not raise ValueError.""" + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": 1280}) + assert payload[0x60] == 255 # 1280 // 5 = 256 → clamped to 255 + + def test_nozzle_temp_negative_clamped_to_zero(self): + payload = _build_payload_from_dict({**MINIMAL_MAPPED, "nozzle_temp_min": -50}) + assert payload[0x60] == 0 + + def test_matches_orm_path_for_same_data(self): + """_build_payload_from_dict must produce identical bytes to _build_payload.""" + spool = _make_spool( + material="PLA", + subtype="Matte", + brand="Polymaker", + color_name="Jade White", + rgba="00AE42FF", + label_weight=1000, + nozzle_temp_min=220, + ) + orm_payload = _build_payload(spool) + dict_payload = _build_payload_from_dict( + { + "material": "PLA", + "subtype": "Matte", + "brand": "Polymaker", + "color_name": "Jade White", + "rgba": "00AE42FF", + "label_weight": 1000, + "nozzle_temp_min": 220, + } + ) + assert orm_payload == dict_payload + + +class TestEncodeOpentag3dFromMapped: + def test_total_size(self): + data = encode_opentag3d_from_mapped(MINIMAL_MAPPED) + assert len(data) == 133 + + def test_starts_with_cc(self): + data = encode_opentag3d_from_mapped(MINIMAL_MAPPED) + assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00]) + + def test_ends_with_terminator(self): + data = encode_opentag3d_from_mapped(MINIMAL_MAPPED) + assert data[-1] == 0xFE + + def test_mime_type_present(self): + data = encode_opentag3d_from_mapped(MINIMAL_MAPPED) + assert b"application/opentag3d" in data + + def test_fits_ntag213(self): + data = encode_opentag3d_from_mapped(MINIMAL_MAPPED) + assert len(data) <= 36 * 4 # 144 bytes + + def test_identical_output_to_orm_path(self): + """encode_opentag3d_from_mapped must produce the same bytes as encode_opentag3d.""" + spool = _make_spool( + material="PLA", + subtype="Matte", + brand="Polymaker", + color_name="Jade White", + rgba="00AE42FF", + label_weight=1000, + nozzle_temp_min=220, + ) + orm_bytes = encode_opentag3d(spool) + mapped_bytes = encode_opentag3d_from_mapped( + { + "material": "PLA", + "subtype": "Matte", + "brand": "Polymaker", + "color_name": "Jade White", + "rgba": "00AE42FF", + "label_weight": 1000, + "nozzle_temp_min": 220, + } + ) + assert orm_bytes == mapped_bytes + + def test_spoolman_mapped_dict_accepted(self): + """Accepts the exact dict shape produced by _map_spoolman_spool.""" + from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool + + raw = { + "id": 7, + "filament": { + "material": "PETG", + "name": "PETG Basic", + "color_hex": "00FF00", + "weight": 1000.0, + "vendor": {"name": "Bambu Lab"}, + }, + "used_weight": 100.0, + "archived": False, + "registered": "2024-01-01T00:00:00Z", + } + mapped = _map_spoolman_spool(raw) + data = encode_opentag3d_from_mapped(mapped) + assert len(data) == 133 + assert data[:4] == bytes([0xE1, 0x10, 0x12, 0x00]) diff --git a/backend/tests/unit/test_spoolbuddy_schema_validation.py b/backend/tests/unit/test_spoolbuddy_schema_validation.py new file mode 100644 index 000000000..0a1af37e0 --- /dev/null +++ b/backend/tests/unit/test_spoolbuddy_schema_validation.py @@ -0,0 +1,197 @@ +"""Unit tests for SpoolBuddy schema validation (security fixes H2, M2, M4). + +Tests Pydantic model validation without requiring a running server or DB. +""" + +import pytest +from pydantic import ValidationError + +from backend.app.schemas.spoolbuddy import ( + DeviceRegisterRequest, + HeartbeatRequest, + ScaleReadingRequest, + UpdateStatusRequest, + WriteTagResultRequest, +) + +# --------------------------------------------------------------------------- +# H2 — UpdateStatusRequest: only valid Literal values accepted +# --------------------------------------------------------------------------- + + +class TestUpdateStatusRequestValidation: + def test_valid_status_updating(self): + req = UpdateStatusRequest(status="updating") + assert req.status == "updating" + + def test_valid_status_complete(self): + req = UpdateStatusRequest(status="complete") + assert req.status == "complete" + + def test_valid_status_error(self): + req = UpdateStatusRequest(status="error") + assert req.status == "error" + + def test_invalid_status_rejected(self): + """Arbitrary status strings must be rejected (H2: prevents unbounded WS injection).""" + with pytest.raises(ValidationError): + UpdateStatusRequest(status="hacked") + + def test_empty_status_rejected(self): + with pytest.raises(ValidationError): + UpdateStatusRequest(status="") + + def test_message_max_length_enforced(self): + """message field must not exceed 255 chars.""" + with pytest.raises(ValidationError): + UpdateStatusRequest(status="updating", message="x" * 256) + + def test_message_at_max_length_accepted(self): + req = UpdateStatusRequest(status="complete", message="x" * 255) + assert len(req.message) == 255 + + +# --------------------------------------------------------------------------- +# M2 — HeartbeatRequest: system_stats size limit (4096 bytes) +# --------------------------------------------------------------------------- + + +class TestHeartbeatSystemStatsValidation: + def test_none_accepted(self): + req = HeartbeatRequest(system_stats=None) + assert req.system_stats is None + + def test_small_dict_accepted(self): + req = HeartbeatRequest(system_stats={"cpu": 12.5, "mem": 60.0}) + assert req.system_stats["cpu"] == 12.5 + + def test_oversized_dict_rejected(self): + """system_stats exceeding 4096 bytes JSON-encoded must be rejected (M2).""" + huge = {"data": "x" * 5000} + with pytest.raises(ValidationError, match="4096"): + HeartbeatRequest(system_stats=huge) + + def test_exactly_4096_bytes_accepted(self): + """A dict whose JSON is exactly 4096 bytes must pass.""" + import json + + # Build a dict whose JSON is exactly 4096 bytes + filler = "x" * (4096 - len('{"k": ""}')) + d = {"k": filler} + assert len(json.dumps(d)) == 4096 + req = HeartbeatRequest(system_stats=d) + assert req.system_stats is not None + + def test_one_byte_over_limit_rejected(self): + import json + + filler = "x" * (4097 - len('{"k": ""}')) + d = {"k": filler} + assert len(json.dumps(d)) == 4097 + with pytest.raises(ValidationError): + HeartbeatRequest(system_stats=d) + + +# --------------------------------------------------------------------------- +# M4 — DeviceRegisterRequest: max_length on device-sourced string fields +# --------------------------------------------------------------------------- + + +class TestDeviceRegisterRequestValidation: + VALID_BASE = {"device_id": "dev1", "hostname": "spoolbuddy.local", "ip_address": "192.168.1.50"} + + def test_valid_minimal_accepted(self): + req = DeviceRegisterRequest(**self.VALID_BASE) + assert req.device_id == "dev1" + + def test_firmware_version_too_long_rejected(self): + with pytest.raises(ValidationError): + DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 21) + + def test_firmware_version_at_max_accepted(self): + req = DeviceRegisterRequest(**self.VALID_BASE, firmware_version="x" * 20) + assert req.firmware_version == "x" * 20 + + def test_nfc_reader_type_too_long_rejected(self): + with pytest.raises(ValidationError): + DeviceRegisterRequest(**self.VALID_BASE, nfc_reader_type="x" * 21) + + def test_nfc_connection_too_long_rejected(self): + with pytest.raises(ValidationError): + DeviceRegisterRequest(**self.VALID_BASE, nfc_connection="x" * 21) + + def test_backend_url_too_long_rejected(self): + with pytest.raises(ValidationError): + DeviceRegisterRequest(**self.VALID_BASE, backend_url="http://" + "x" * 249) + + def test_backend_url_at_max_accepted(self): + url = "http://" + "x" * (255 - len("http://")) + req = DeviceRegisterRequest(**self.VALID_BASE, backend_url=url) + assert req.backend_url == url + + def test_device_id_too_long_rejected(self): + with pytest.raises(ValidationError): + DeviceRegisterRequest(device_id="x" * 51, hostname="h", ip_address="1.2.3.4") + + +# --------------------------------------------------------------------------- +# M4 — WriteTagResultRequest: device_id max_length +# --------------------------------------------------------------------------- + + +class TestWriteTagResultRequestValidation: + def test_device_id_too_long_rejected(self): + with pytest.raises(ValidationError): + WriteTagResultRequest(device_id="x" * 51, spool_id=1, tag_uid="AABBCCDD", success=True) + + def test_device_id_at_max_accepted(self): + req = WriteTagResultRequest(device_id="x" * 50, spool_id=1, tag_uid="AABBCCDD", success=True) + assert len(req.device_id) == 50 + + def test_tag_uid_hex_pattern_accepted(self): + req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABBCCDD", success=True) + assert req.tag_uid == "AABBCCDD" + + def test_tag_uid_non_hex_rejected(self): + """Non-hex characters in tag_uid must be rejected (prevents injection via NFC write-back).""" + with pytest.raises(ValidationError): + WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB; DROP", success=True) + + def test_tag_uid_too_short_rejected(self): + with pytest.raises(ValidationError): + WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="AABB", success=True) + + def test_tag_uid_max_length_accepted(self): + req = WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 30, success=True) + assert len(req.tag_uid) == 30 + + def test_tag_uid_over_max_length_rejected(self): + with pytest.raises(ValidationError): + WriteTagResultRequest(device_id="dev1", spool_id=1, tag_uid="A" * 31, success=True) + + +# --------------------------------------------------------------------------- +# M4 — ScaleReadingRequest: weight_grams bounds +# --------------------------------------------------------------------------- + + +class TestScaleReadingRequestValidation: + def test_valid_weight_accepted(self): + req = ScaleReadingRequest(device_id="sb1", weight_grams=250.0) + assert req.weight_grams == 250.0 + + def test_zero_weight_accepted(self): + req = ScaleReadingRequest(device_id="sb1", weight_grams=0.0) + assert req.weight_grams == 0.0 + + def test_max_weight_accepted(self): + req = ScaleReadingRequest(device_id="sb1", weight_grams=100_000.0) + assert req.weight_grams == 100_000.0 + + def test_negative_weight_rejected(self): + with pytest.raises(ValidationError): + ScaleReadingRequest(device_id="sb1", weight_grams=-1.0) + + def test_over_max_weight_rejected(self): + with pytest.raises(ValidationError): + ScaleReadingRequest(device_id="sb1", weight_grams=100_001.0) diff --git a/backend/tests/unit/services/test_spoolbuddy_ssh.py b/backend/tests/unit/test_spoolbuddy_ssh.py similarity index 60% rename from backend/tests/unit/services/test_spoolbuddy_ssh.py rename to backend/tests/unit/test_spoolbuddy_ssh.py index 1fbfd430e..56597d299 100644 --- a/backend/tests/unit/services/test_spoolbuddy_ssh.py +++ b/backend/tests/unit/test_spoolbuddy_ssh.py @@ -223,25 +223,30 @@ async def test_run_ssh_command_success(tmp_path): mock_result.stderr = "" mock_result.exit_status = 0 + mock_server_key = MagicMock() + mock_server_key.export_public_key.return_value = b"ssh-ed25519 AAAA test" + mock_conn = AsyncMock() mock_conn.run = AsyncMock(return_value=mock_result) + mock_conn.get_server_host_key = MagicMock(return_value=mock_server_key) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn) as mock_connect: - rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file) + rc, stdout, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file) assert rc == 0 assert stdout == "hello\n" assert stderr == "" + # TOFU mode (no known_hosts): returns observed key + assert observed_key == "ssh-ed25519 AAAA test" kwargs = mock_connect.call_args.kwargs assert kwargs["host"] == "10.0.0.1" assert kwargs["username"] == "spoolbuddy" assert kwargs["client_keys"] == [str(key_file)] - # Host-key verification is disabled (equivalent to StrictHostKeyChecking=no) + # TOFU default: known_hosts=None on first connect assert kwargs["known_hosts"] is None - # ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary - # Docker PUIDs. + # ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary Docker PUIDs assert kwargs["config"] == [] mock_conn.run.assert_awaited_once() run_args = mock_conn.run.call_args @@ -251,12 +256,10 @@ async def test_run_ssh_command_success(tmp_path): @pytest.mark.asyncio -async def test_run_ssh_command_no_subprocess(tmp_path): - """Regression guard: _run_ssh_command must not spawn any subprocess. +async def test_run_ssh_command_with_known_hosts_skips_capture(tmp_path): + """When known_hosts is provided, observed_host_key must be None.""" + import asyncssh - The whole point of switching to asyncssh is to avoid `ssh`/`ssh-keygen` - calling getpwuid() inside Docker containers with arbitrary PUIDs. - """ key_file = tmp_path / "key" key_file.write_text("KEY") @@ -267,6 +270,52 @@ async def test_run_ssh_command_no_subprocess(tmp_path): mock_conn = AsyncMock() mock_conn.run = AsyncMock(return_value=mock_result) + mock_conn.get_server_host_key = MagicMock() + mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) + mock_conn.__aexit__ = AsyncMock(return_value=False) + + fake_kh = MagicMock(spec=asyncssh.SSHKnownHosts) + with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn): + rc, _, _, observed_key = await _run_ssh_command("10.0.0.1", "echo hi", key_file, known_hosts=fake_kh) + + assert rc == 0 + assert observed_key is None + mock_conn.get_server_host_key.assert_not_called() + + +@pytest.mark.asyncio +async def test_run_ssh_command_host_key_mismatch(tmp_path): + """HostKeyNotVerifiable must surface as rc=255 with a safe message (H1).""" + import asyncssh + + key_file = tmp_path / "key" + key_file.write_text("KEY") + + with patch( + "backend.app.services.spoolbuddy_ssh.asyncssh.connect", + side_effect=asyncssh.HostKeyNotVerifiable(asyncssh.DISC_HOST_KEY_NOT_VERIFIABLE, "key mismatch"), + ): + rc, _, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file) + + assert rc == 255 + assert "mismatch" in stderr.lower() + assert observed_key is None + + +@pytest.mark.asyncio +async def test_run_ssh_command_no_subprocess(tmp_path): + """Regression guard: _run_ssh_command must not spawn any subprocess.""" + key_file = tmp_path / "key" + key_file.write_text("KEY") + + mock_result = MagicMock() + mock_result.stdout = "" + mock_result.stderr = "" + mock_result.exit_status = 0 + + mock_conn = AsyncMock() + mock_conn.run = AsyncMock(return_value=mock_result) + mock_conn.get_server_host_key = MagicMock(return_value=None) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) @@ -291,7 +340,7 @@ async def test_run_ssh_command_connection_failure(tmp_path): "backend.app.services.spoolbuddy_ssh.asyncssh.connect", side_effect=asyncssh.Error(code=0, reason="Connection refused"), ): - rc, stdout, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file) + rc, stdout, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file) assert rc == 255 assert stdout == "" @@ -308,7 +357,7 @@ async def test_run_ssh_command_os_error(tmp_path): "backend.app.services.spoolbuddy_ssh.asyncssh.connect", side_effect=OSError("Network is unreachable"), ): - rc, _, stderr = await _run_ssh_command("10.0.0.1", "echo hello", key_file) + rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file) assert rc == 255 assert "Network is unreachable" in stderr @@ -320,9 +369,6 @@ async def test_run_ssh_command_timeout(tmp_path): key_file = tmp_path / "key" key_file.write_text("KEY") - # asyncssh.connect() returns a _ConnectionManager synchronously; the hang - # must happen inside __aenter__ so the surrounding asyncio.timeout can - # cancel it. mock_conn = AsyncMock() async def hang_enter(): @@ -332,7 +378,7 @@ async def test_run_ssh_command_timeout(tmp_path): mock_conn.__aexit__ = AsyncMock(return_value=False) with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn): - rc, _, stderr = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=0.05) + rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=0.05) assert rc == -1 assert "timed out" in stderr @@ -347,6 +393,7 @@ def _make_update_mocks(tmp_path): mock_db_device.update_status = None mock_db_device.update_message = None mock_db_device.pending_command = None + mock_db_device.ssh_host_key = None # TOFU: no stored key mock_result = MagicMock() mock_result.scalar_one_or_none.return_value = mock_db_device @@ -375,9 +422,9 @@ async def test_perform_ssh_update_success(tmp_path): ssh_calls = [] - async def mock_ssh(ip, cmd, key, timeout=60): + async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): ssh_calls.append(cmd) - return 0, "ok", "" + return 0, "ok", "", "ssh-ed25519 AAAA fakehostkey" _, mock_ctx, mock_ws = _make_update_mocks(tmp_path) @@ -385,6 +432,7 @@ async def test_perform_ssh_update_success(tmp_path): patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings, patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh), patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="dev"), + patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()), patch("backend.app.core.database.async_session", return_value=mock_ctx), patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws), ): @@ -405,6 +453,82 @@ async def test_perform_ssh_update_success(tmp_path): assert mock_ws.broadcast.call_count >= 4 +@pytest.mark.asyncio +async def test_perform_ssh_update_branch_is_shell_quoted(tmp_path): + """Branch name with shell-special chars must be quoted in all git commands (L1 fix).""" + import shlex + + ssh_dir = tmp_path / "spoolbuddy" / "ssh" + ssh_dir.mkdir(parents=True) + (ssh_dir / "id_ed25519").write_text("PRIVATE") + (ssh_dir / "id_ed25519.pub").write_text("PUBLIC") + + # A branch name containing a semicolon — shell-injection without quoting + dangerous_branch = "dev; echo pwned" + safe_branch = shlex.quote(dangerous_branch) # expected: "'dev; echo pwned'" + + ssh_calls = [] + + async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): + ssh_calls.append(cmd) + return 0, "ok", "", None + + _, mock_ctx, mock_ws = _make_update_mocks(tmp_path) + + with ( + patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings, + patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh), + patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value=dangerous_branch), + patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()), + patch("backend.app.core.database.async_session", return_value=mock_ctx), + patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws), + ): + mock_settings.base_dir = tmp_path + await perform_ssh_update("sb-test", "10.0.0.1") + + # All git commands must use the shell-quoted form, never the raw dangerous string + git_cmds = [c for c in ssh_calls if "fetch" in c or "checkout" in c or "reset" in c] + for cmd in git_cmds: + assert safe_branch in cmd, f"Branch not shell-quoted in: {cmd}" + assert dangerous_branch not in cmd.replace(safe_branch, ""), f"Raw dangerous branch in: {cmd}" + + +@pytest.mark.asyncio +async def test_perform_ssh_update_tofu_stores_host_key(tmp_path): + """On first connect (no stored key), the observed host key must be persisted (H1).""" + ssh_dir = tmp_path / "spoolbuddy" / "ssh" + ssh_dir.mkdir(parents=True) + (ssh_dir / "id_ed25519").write_text("PRIVATE") + (ssh_dir / "id_ed25519.pub").write_text("PUBLIC") + + FAKE_HOST_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 fakehostkey" + call_count = 0 + + async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): + nonlocal call_count + call_count += 1 + # Only first call returns the observed host key (TOFU) + observed = FAKE_HOST_KEY if call_count == 1 else None + return 0, "ok", "", observed + + mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path) + mock_device.ssh_host_key = None # no stored key + + with ( + patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings, + patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh), + patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"), + patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()), + patch("backend.app.core.database.async_session", return_value=mock_ctx), + patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws), + ): + mock_settings.base_dir = tmp_path + await perform_ssh_update("sb-test", "10.0.0.1") + + # Device's ssh_host_key should have been set to the observed key + assert mock_device.ssh_host_key == FAKE_HOST_KEY + + @pytest.mark.asyncio async def test_perform_ssh_update_ssh_failure(tmp_path): """SSH connectivity check fails — should set error status.""" @@ -413,10 +537,10 @@ async def test_perform_ssh_update_ssh_failure(tmp_path): (ssh_dir / "id_ed25519").write_text("PRIVATE") (ssh_dir / "id_ed25519.pub").write_text("PUBLIC") - async def mock_ssh(ip, cmd, key, timeout=60): + async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): if "echo ok" in cmd: - return 255, "", "Connection refused" - return 0, "", "" + return 255, "", "Connection refused", None + return 0, "", "", None mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path) @@ -446,11 +570,11 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path): ssh_calls = [] - async def mock_ssh(ip, cmd, key, timeout=60): + async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): ssh_calls.append(cmd) if "fetch" in cmd: - return 1, "", "fatal: could not read from remote" - return 0, "ok", "" + return 1, "", "fatal: could not read from remote", None + return 0, "ok", "", None _, mock_ctx, mock_ws = _make_update_mocks(tmp_path) @@ -458,6 +582,7 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path): patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings, patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh), patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"), + patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()), patch("backend.app.core.database.async_session", return_value=mock_ctx), patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws), ): @@ -467,3 +592,83 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path): # Should stop after git fetch — no checkout, pip, restart assert len(ssh_calls) == 2 # echo ok + git fetch assert not any("checkout" in c for c in ssh_calls) + + +@pytest.mark.asyncio +async def test_perform_ssh_update_uses_stored_host_key(tmp_path): + """When device already has ssh_host_key set, all SSH calls must receive non-None known_hosts (Gap 1).""" + ssh_dir = tmp_path / "spoolbuddy" / "ssh" + ssh_dir.mkdir(parents=True) + (ssh_dir / "id_ed25519").write_text("PRIVATE") + (ssh_dir / "id_ed25519.pub").write_text("PUBLIC") + + STORED_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 storedkey" + SENTINEL_KNOWN_HOSTS = MagicMock(name="known_hosts_sentinel") + received_known_hosts = [] + + async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): + received_known_hosts.append(known_hosts) + return 0, "ok", "", None # no new observed key (already stored) + + mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path) + mock_device.ssh_host_key = STORED_KEY + + with ( + patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings, + patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh), + patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"), + patch( + "backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", + return_value=SENTINEL_KNOWN_HOSTS, + ), + patch("backend.app.core.database.async_session", return_value=mock_ctx), + patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws), + ): + mock_settings.base_dir = tmp_path + await perform_ssh_update("sb-test", "10.0.0.1") + + # Every SSH call must have received the sentinel known_hosts object (not None) + assert len(received_known_hosts) >= 2, "Expected at least 2 SSH calls" + for kh in received_known_hosts: + assert kh is SENTINEL_KNOWN_HOSTS, f"Expected sentinel known_hosts but got: {kh}" + + +@pytest.mark.asyncio +async def test_perform_ssh_update_corrupt_stored_key_falls_back_to_tofu(tmp_path): + """When stored ssh_host_key can't be parsed, update continues with known_hosts=None (Gap 2).""" + ssh_dir = tmp_path / "spoolbuddy" / "ssh" + ssh_dir.mkdir(parents=True) + (ssh_dir / "id_ed25519").write_text("PRIVATE") + (ssh_dir / "id_ed25519.pub").write_text("PUBLIC") + + ssh_calls = [] + + async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): + ssh_calls.append(cmd) + return 0, "ok", "", None + + mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path) + mock_device.ssh_host_key = "THIS-IS-NOT-A-VALID-KEY" + + with ( + patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings, + patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh), + patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"), + patch( + "backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", + side_effect=ValueError("Malformed key"), + ), + patch("backend.app.core.database.async_session", return_value=mock_ctx), + patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws), + ): + mock_settings.base_dir = tmp_path + # Must not raise — corrupt key degrades gracefully + await perform_ssh_update("sb-test", "10.0.0.1") + + # Update must have completed all steps despite the corrupt key + assert any("echo ok" in c for c in ssh_calls) + assert any("fetch" in c for c in ssh_calls) + assert any("checkout" in c for c in ssh_calls) + # Broadcast must show success, not error + error_broadcasts = [c for c in mock_ws.broadcast.call_args_list if c[0][0].get("update_status") == "error"] + assert not error_broadcasts, f"Got unexpected error broadcast: {error_broadcasts}" diff --git a/backend/tests/unit/test_spoolman_inventory_helpers.py b/backend/tests/unit/test_spoolman_inventory_helpers.py new file mode 100644 index 000000000..35ff44b17 --- /dev/null +++ b/backend/tests/unit/test_spoolman_inventory_helpers.py @@ -0,0 +1,246 @@ +"""Unit tests for _safe_int, _safe_float, and _map_spoolman_spool helpers.""" + +import math + +import pytest + +from backend.app.api.routes._spoolman_helpers import ( + _map_spoolman_spool, + _safe_float, + _safe_int, +) + +# --------------------------------------------------------------------------- +# _safe_int +# --------------------------------------------------------------------------- + + +class TestSafeInt: + def test_normal_int(self): + assert _safe_int(1000, 0) == 1000 + + def test_float_rounds_down(self): + assert _safe_int(750.9, 0) == 750 + + def test_none_returns_fallback(self): + assert _safe_int(None, 999) == 999 + + def test_nan_returns_fallback(self): + assert _safe_int(math.nan, 999) == 999 + + def test_inf_returns_fallback(self): + assert _safe_int(math.inf, 999) == 999 + + def test_neg_inf_returns_fallback(self): + assert _safe_int(-math.inf, 999) == 999 + + def test_string_numeric(self): + assert _safe_int("500", 0) == 500 + + def test_string_non_numeric_returns_fallback(self): + assert _safe_int("abc", 42) == 42 + + def test_zero(self): + assert _safe_int(0, 999) == 0 + + +# --------------------------------------------------------------------------- +# _safe_float +# --------------------------------------------------------------------------- + + +class TestSafeFloat: + def test_normal_float(self): + assert _safe_float(123.45, 0.0) == pytest.approx(123.45) + + def test_none_returns_fallback(self): + assert _safe_float(None, -1.0) == -1.0 + + def test_nan_returns_fallback(self): + assert _safe_float(math.nan, -1.0) == -1.0 + + def test_inf_returns_fallback(self): + assert _safe_float(math.inf, -1.0) == -1.0 + + def test_neg_inf_returns_fallback(self): + assert _safe_float(-math.inf, -1.0) == -1.0 + + def test_string_numeric(self): + assert _safe_float("3.14", 0.0) == pytest.approx(3.14) + + def test_string_non_numeric_returns_fallback(self): + assert _safe_float("bad", 0.0) == 0.0 + + def test_zero(self): + assert _safe_float(0.0, 99.0) == 0.0 + + +# --------------------------------------------------------------------------- +# _map_spoolman_spool +# --------------------------------------------------------------------------- + + +MINIMAL_SPOOL = { + "id": 1, + "filament": { + "material": "PLA", + "name": "PLA Basic", + "color_hex": "FF0000", + "weight": 1000.0, + "vendor": {"name": "Bambu Lab"}, + }, + "used_weight": 250.0, + "archived": False, + "registered": "2024-01-01T00:00:00Z", +} + + +class TestMapSpoolmanSpool: + def test_basic_mapping(self): + result = _map_spoolman_spool(MINIMAL_SPOOL) + assert result["id"] == 1 + assert result["material"] == "PLA" + assert result["rgba"] == "FF0000FF" + assert result["label_weight"] == 1000 + assert result["weight_used"] == pytest.approx(250.0) + assert result["data_origin"] == "spoolman" + + def test_missing_id_raises(self): + spool = {k: v for k, v in MINIMAL_SPOOL.items() if k != "id"} + with pytest.raises(ValueError, match="missing required 'id'"): + _map_spoolman_spool(spool) + + def test_none_id_raises(self): + with pytest.raises(ValueError): + _map_spoolman_spool({**MINIMAL_SPOOL, "id": None}) + + def test_string_id_raises(self): + with pytest.raises(ValueError, match="not a valid integer"): + _map_spoolman_spool({**MINIMAL_SPOOL, "id": "abc"}) + + def test_zero_id_raises(self): + with pytest.raises(ValueError, match="positive integer"): + _map_spoolman_spool({**MINIMAL_SPOOL, "id": 0}) + + def test_negative_id_raises(self): + with pytest.raises(ValueError, match="positive integer"): + _map_spoolman_spool({**MINIMAL_SPOOL, "id": -5}) + + def test_numeric_string_id_accepted(self): + result = _map_spoolman_spool({**MINIMAL_SPOOL, "id": "42"}) + assert result["id"] == 42 + + def test_zero_price_not_converted_to_none(self): + spool = {**MINIMAL_SPOOL, "price": 0.0} + result = _map_spoolman_spool(spool) + assert result["cost_per_kg"] == 0.0 + + def test_nonzero_price_preserved(self): + spool = {**MINIMAL_SPOOL, "price": 9.99} + result = _map_spoolman_spool(spool) + assert result["cost_per_kg"] == pytest.approx(9.99) + + def test_none_price_stays_none(self): + spool = {**MINIMAL_SPOOL, "price": None} + result = _map_spoolman_spool(spool) + assert result["cost_per_kg"] is None + + def test_infinity_weight_falls_back(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "weight": math.inf}} + result = _map_spoolman_spool(spool) + assert result["label_weight"] == 1000 + + def test_nan_used_weight_falls_back(self): + spool = {**MINIMAL_SPOOL, "used_weight": math.nan} + result = _map_spoolman_spool(spool) + assert result["weight_used"] == 0.0 + + def test_invalid_color_hex_falls_back_to_grey(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ZZZZZZ"}} + result = _map_spoolman_spool(spool) + assert result["rgba"] == "808080FF" + + def test_short_color_hex_falls_back(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FFF"}} + result = _map_spoolman_spool(spool) + assert result["rgba"] == "808080FF" + + def test_eight_char_color_hex_falls_back(self): + # Only 6-char hex is valid from Spoolman; 8-char (RGBA) should fall back + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "FF0000FF"}} + result = _map_spoolman_spool(spool) + assert result["rgba"] == "808080FF" + + def test_color_hex_with_hash_prefix_stripped(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "#00FF00"}} + result = _map_spoolman_spool(spool) + assert result["rgba"] == "00FF00FF" + + def test_color_hex_lowercase_normalised(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "color_hex": "ff0000"}} + result = _map_spoolman_spool(spool) + assert result["rgba"] == "FF0000FF" + + def test_none_filament(self): + spool = {**MINIMAL_SPOOL, "filament": None} + result = _map_spoolman_spool(spool) + assert result["material"] == "" + assert result["rgba"] == "808080FF" + assert result["label_weight"] == 1000 + + def test_archived_spool_has_archived_at(self): + spool = {**MINIMAL_SPOOL, "archived": True} + result = _map_spoolman_spool(spool) + assert result["archived_at"] is not None + + def test_subtype_strips_material_prefix(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "material": "PLA", "name": "PLA Basic"}} + result = _map_spoolman_spool(spool) + assert result["subtype"] == "Basic" + + def test_brand_from_vendor(self): + result = _map_spoolman_spool(MINIMAL_SPOOL) + assert result["brand"] == "Bambu Lab" + + def test_no_vendor_brand_is_none(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "vendor": None}} + result = _map_spoolman_spool(spool) + assert result["brand"] is None + + def test_spoolman_location_mapped_to_storage_location(self): + spool = {**MINIMAL_SPOOL, "location": "Shelf A"} + result = _map_spoolman_spool(spool) + assert result["storage_location"] == "Shelf A" + + def test_no_location_gives_none_storage_location(self): + result = _map_spoolman_spool(MINIMAL_SPOOL) + assert result["storage_location"] is None + + def test_empty_location_gives_none_storage_location(self): + spool = {**MINIMAL_SPOOL, "location": ""} + result = _map_spoolman_spool(spool) + assert result["storage_location"] is None + + def test_spoolman_location_key_not_in_result(self): + spool = {**MINIMAL_SPOOL, "location": "Shelf A"} + result = _map_spoolman_spool(spool) + assert "spoolman_location" not in result + + def test_core_weight_from_filament_spool_weight(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 196}} + result = _map_spoolman_spool(spool) + assert result["core_weight"] == 196 + + def test_core_weight_fallback_when_spool_weight_missing(self): + result = _map_spoolman_spool(MINIMAL_SPOOL) + assert result["core_weight"] == 250 + + def test_core_weight_fallback_when_spool_weight_none(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": None}} + result = _map_spoolman_spool(spool) + assert result["core_weight"] == 250 + + def test_core_weight_float_truncated_to_int(self): + spool = {**MINIMAL_SPOOL, "filament": {**MINIMAL_SPOOL["filament"], "spool_weight": 180.9}} + result = _map_spoolman_spool(spool) + assert result["core_weight"] == 180 diff --git a/backend/tests/unit/test_spoolman_inventory_methods.py b/backend/tests/unit/test_spoolman_inventory_methods.py new file mode 100644 index 000000000..ceaf58a23 --- /dev/null +++ b/backend/tests/unit/test_spoolman_inventory_methods.py @@ -0,0 +1,424 @@ +"""Unit tests for new SpoolmanClient inventory methods. + +Covers: get_spool, get_all_spools, delete_spool, set_spool_archived, +update_spool_full, find_or_create_vendor, find_or_create_filament. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from backend.app.services.spoolman import SpoolmanClient, SpoolmanUnavailableError + + +@pytest.fixture +def client(): + return SpoolmanClient("http://localhost:7912") + + +def _make_response(json_data, status_code=200): + """Build a mock httpx response.""" + resp = MagicMock() + resp.status_code = status_code + resp.json.return_value = json_data + resp.raise_for_status = MagicMock() + return resp + + +SAMPLE_SPOOL = { + "id": 42, + "remaining_weight": 750.0, + "used_weight": 250.0, + "archived": False, + "filament": {"id": 7, "name": "PLA Basic", "material": "PLA"}, +} + +SAMPLE_FILAMENT = { + "id": 7, + "name": "PLA Basic", + "material": "PLA", + "color_hex": "FF0000", + "weight": 1000.0, + "vendor": {"id": 3, "name": "Bambu Lab"}, +} + +SAMPLE_VENDOR = {"id": 3, "name": "Bambu Lab"} + + +# --------------------------------------------------------------------------- +# get_spool +# --------------------------------------------------------------------------- + + +class TestGetSpool: + @pytest.mark.asyncio + async def test_returns_spool_dict_on_success(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + result = await client.get_spool(42) + assert result == SAMPLE_SPOOL + mock_http.request.assert_called_once_with("GET", "http://localhost:7912/api/v1/spool/42", json=None) + + @pytest.mark.asyncio + async def test_raises_unavailable_on_http_error(self, client): + from backend.app.services.spoolman import SpoolmanUnavailableError + + mock_http = AsyncMock() + mock_http.request = AsyncMock(side_effect=Exception("not found")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.get_spool(99) + + @pytest.mark.asyncio + async def test_raises_not_found_on_404_response(self, client): + """get_spool raises SpoolmanNotFoundError when Spoolman returns HTTP 404 (PT-I3).""" + from backend.app.services.spoolman import SpoolmanNotFoundError + + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(None, status_code=404)) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanNotFoundError), + ): + await client.get_spool(99) + + @pytest.mark.asyncio + async def test_raises_client_error_on_4xx_response(self, client): + """get_spool raises SpoolmanClientError (not SpoolmanUnavailableError) on non-404 4xx (H2).""" + from backend.app.services.spoolman import SpoolmanClientError + + mock_request = MagicMock() + mock_request.url = "http://localhost:7912/api/v1/spool/42" + mock_resp_obj = MagicMock() + mock_resp_obj.status_code = 422 + + mock_http = AsyncMock() + resp = _make_response(None, status_code=422) + resp.raise_for_status = MagicMock( + side_effect=httpx.HTTPStatusError("Unprocessable", request=mock_request, response=mock_resp_obj) + ) + mock_http.request = AsyncMock(return_value=resp) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanClientError) as exc_info, + ): + await client.get_spool(42) + assert exc_info.value.status_code == 422 + + +# --------------------------------------------------------------------------- +# get_all_spools +# --------------------------------------------------------------------------- + + +class TestGetAllSpools: + @pytest.mark.asyncio + async def test_returns_list_without_archived_by_default(self, client): + mock_http = AsyncMock() + mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL])) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + result = await client.get_all_spools() + assert result == [SAMPLE_SPOOL] + mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params=None) + + @pytest.mark.asyncio + async def test_passes_allow_archived_param(self, client): + mock_http = AsyncMock() + mock_http.get = AsyncMock(return_value=_make_response([SAMPLE_SPOOL])) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.get_all_spools(allow_archived=True) + mock_http.get.assert_called_once_with("http://localhost:7912/api/v1/spool", params={"allow_archived": "true"}) + + @pytest.mark.asyncio + async def test_raises_unavailable_on_error(self, client): + mock_http = AsyncMock() + mock_http.get = AsyncMock(side_effect=Exception("connection error")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.get_all_spools() + + +# --------------------------------------------------------------------------- +# delete_spool +# --------------------------------------------------------------------------- + + +class TestDeleteSpool: + @pytest.mark.asyncio + async def test_returns_none_on_success(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(None)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + result = await client.delete_spool(42) + assert result is None + mock_http.request.assert_called_once_with("DELETE", "http://localhost:7912/api/v1/spool/42", json=None) + + @pytest.mark.asyncio + async def test_raises_unavailable_on_error(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(side_effect=Exception("server error")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.delete_spool(42) + + +# --------------------------------------------------------------------------- +# set_spool_archived +# --------------------------------------------------------------------------- + + +class TestSetSpoolArchived: + @pytest.mark.asyncio + async def test_archives_spool(self, client): + archived_spool = {**SAMPLE_SPOOL, "archived": True} + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(archived_spool)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + result = await client.set_spool_archived(42, archived=True) + assert result == archived_spool + mock_http.request.assert_called_once_with( + "PATCH", + "http://localhost:7912/api/v1/spool/42", + json={"archived": True}, + ) + + @pytest.mark.asyncio + async def test_restores_spool(self, client): + restored_spool = {**SAMPLE_SPOOL, "archived": False} + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(restored_spool)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + result = await client.set_spool_archived(42, archived=False) + assert result == restored_spool + mock_http.request.assert_called_once_with( + "PATCH", + "http://localhost:7912/api/v1/spool/42", + json={"archived": False}, + ) + + @pytest.mark.asyncio + async def test_raises_unavailable_on_error(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(side_effect=Exception("timeout")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.set_spool_archived(42, archived=True) + + +# --------------------------------------------------------------------------- +# update_spool_full +# --------------------------------------------------------------------------- + + +class TestUpdateSpoolFull: + @pytest.mark.asyncio + async def test_sends_only_provided_fields(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.update_spool_full(42, remaining_weight=600.0, comment="note") + call_json = mock_http.request.call_args.kwargs["json"] + assert call_json == {"remaining_weight": 600.0, "comment": "note"} + + @pytest.mark.asyncio + async def test_clear_location_sets_none(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.update_spool_full(42, clear_location=True) + call_json = mock_http.request.call_args.kwargs["json"] + assert call_json == {"location": None} + + @pytest.mark.asyncio + async def test_location_set_when_not_clearing(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.update_spool_full(42, location="Shelf A") + call_json = mock_http.request.call_args.kwargs["json"] + assert call_json == {"location": "Shelf A"} + + @pytest.mark.asyncio + async def test_empty_comment_sent_as_none(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(return_value=_make_response(SAMPLE_SPOOL)) + with patch.object(client, "_get_client", AsyncMock(return_value=mock_http)): + await client.update_spool_full(42, comment="") + call_json = mock_http.request.call_args.kwargs["json"] + assert call_json == {"comment": None} + + @pytest.mark.asyncio + async def test_raises_unavailable_on_error(self, client): + mock_http = AsyncMock() + mock_http.request = AsyncMock(side_effect=Exception("network")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.update_spool_full(42, remaining_weight=500.0) + + +# --------------------------------------------------------------------------- +# find_or_create_vendor +# --------------------------------------------------------------------------- + + +class TestFindOrCreateVendor: + @pytest.mark.asyncio + async def test_returns_existing_vendor_id(self, client): + with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])): + result = await client.find_or_create_vendor("Bambu Lab") + assert result == 3 + + @pytest.mark.asyncio + async def test_case_insensitive_match(self, client): + with patch.object(client, "get_vendors", AsyncMock(return_value=[SAMPLE_VENDOR])): + result = await client.find_or_create_vendor("bambu lab") + assert result == 3 + + @pytest.mark.asyncio + async def test_creates_vendor_when_not_found(self, client): + new_vendor = {"id": 10, "name": "New Brand"} + with ( + patch.object(client, "get_vendors", AsyncMock(return_value=[])), + patch.object(client, "create_vendor", AsyncMock(return_value=new_vendor)) as mock_create, + ): + result = await client.find_or_create_vendor("New Brand") + assert result == 10 + mock_create.assert_called_once_with("New Brand") + + @pytest.mark.asyncio + async def test_returns_none_when_create_fails(self, client): + with ( + patch.object(client, "get_vendors", AsyncMock(return_value=[])), + patch.object(client, "create_vendor", AsyncMock(return_value=None)), + ): + result = await client.find_or_create_vendor("Ghost Brand") + assert result is None + + +# --------------------------------------------------------------------------- +# find_or_create_filament +# --------------------------------------------------------------------------- + + +class TestFindOrCreateFilament: + @pytest.mark.asyncio + async def test_returns_existing_filament_id(self, client): + with ( + patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)), + patch.object(client, "get_filaments", AsyncMock(return_value=[SAMPLE_FILAMENT])), + ): + result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000) + assert result == 7 + + @pytest.mark.asyncio + async def test_creates_filament_when_no_match(self, client): + new_filament = {"id": 99, "name": "PETG Pro"} + with ( + patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)), + patch.object(client, "get_filaments", AsyncMock(return_value=[])), + patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create, + ): + result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000) + assert result == 99 + mock_create.assert_called_once_with( + name="PETG Pro", + vendor_id=3, + material="PETG", + color_hex="00FF00", + color_name=None, + weight=1000.0, + ) + + @pytest.mark.asyncio + async def test_no_brand_skips_vendor_lookup(self, client): + filament_no_vendor = { + **SAMPLE_FILAMENT, + "vendor": None, + "name": "PLA Basic", + "color_hex": "FF0000", + } + with ( + patch.object(client, "get_filaments", AsyncMock(return_value=[filament_no_vendor])), + ): + result = await client.find_or_create_filament("PLA", "Basic", None, "FF0000", 1000) + assert result == 7 + + @pytest.mark.asyncio + async def test_color_hex_normalised_to_uppercase(self, client): + with ( + patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)), + patch.object(client, "get_filaments", AsyncMock(return_value=[])), + patch.object(client, "create_filament", AsyncMock(return_value={"id": 5})) as mock_create, + ): + await client.find_or_create_filament("ABS", "", None, "ff0000", 750) + mock_create.assert_called_once_with( + name="ABS", + vendor_id=None, + material="ABS", + color_hex="FF0000", + color_name=None, + weight=750.0, + ) + + @pytest.mark.asyncio + async def test_returns_none_when_create_fails(self, client): + with ( + patch.object(client, "find_or_create_vendor", AsyncMock(return_value=None)), + patch.object(client, "get_filaments", AsyncMock(return_value=[])), + patch.object(client, "create_filament", AsyncMock(return_value=None)), + ): + result = await client.find_or_create_filament("TPU", "Flex", "Generic", "000000", 500) + assert result is None + + +# --------------------------------------------------------------------------- +# get_filaments / get_vendors / get_external_filaments error propagation (H11) +# --------------------------------------------------------------------------- + + +class TestGetFilamentsRaisesOnError: + @pytest.mark.asyncio + async def test_raises_unavailable_on_error(self, client): + mock_http = AsyncMock() + mock_http.get = AsyncMock(side_effect=Exception("timeout")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.get_filaments() + + +class TestGetVendorsRaisesOnError: + @pytest.mark.asyncio + async def test_raises_unavailable_on_error(self, client): + mock_http = AsyncMock() + mock_http.get = AsyncMock(side_effect=Exception("timeout")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.get_vendors() + + +class TestGetExternalFilamentsRaisesOnError: + @pytest.mark.asyncio + async def test_raises_unavailable_on_error(self, client): + mock_http = AsyncMock() + mock_http.get = AsyncMock(side_effect=Exception("timeout")) + with ( + patch.object(client, "_get_client", AsyncMock(return_value=mock_http)), + pytest.raises(SpoolmanUnavailableError), + ): + await client.get_external_filaments() diff --git a/backend/tests/unit/test_spoolman_tracking.py b/backend/tests/unit/test_spoolman_tracking.py new file mode 100644 index 000000000..610176e6c --- /dev/null +++ b/backend/tests/unit/test_spoolman_tracking.py @@ -0,0 +1,223 @@ +"""Unit tests for Spoolman tracking service helpers.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.app.services.spoolman_tracking import ( + _get_fallback_spool_tag, + _global_tray_id_to_ams_slot, + _hash_serial_to_hex32, + _resolve_global_tray_id, + _resolve_spool_tag, + build_ams_tray_lookup, + store_print_data, +) + + +class TestResolveSpoolTag: + """Tests for _resolve_spool_tag().""" + + def test_prefers_tray_uuid_over_tag_uid(self): + tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": "DEADBEEF"} + assert _resolve_spool_tag(tray) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" + + def test_falls_back_to_tag_uid_when_no_uuid(self): + tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"} + assert _resolve_spool_tag(tray) == "DEADBEEF" + + def test_falls_back_to_tag_uid_when_uuid_zero(self): + tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"} + assert _resolve_spool_tag(tray) == "DEADBEEF" + + def test_rejects_zero_tag_uid(self): + tray = {"tray_uuid": "", "tag_uid": "0000000000000000"} + assert _resolve_spool_tag(tray) == "" + + def test_uses_fallback_tag_when_ids_missing(self): + tray = {"tray_uuid": "", "tag_uid": ""} + # global_tray_id 0 -> ams_id 0, tray_id 0 + assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "ABA7845700000000" + + def test_uses_fallback_tag_when_ids_zero(self): + tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "0000000000000000"} + # global_tray_id 5 -> ams_id 1, tray_id 1 + assert _resolve_spool_tag(tray, "01P00A000000000", 5) == "ABA7845700010001" + + def test_prefers_tray_uuid_over_fallback_when_non_zero(self): + tray = {"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4", "tag_uid": ""} + assert _resolve_spool_tag(tray, "01P00A000000000", 0) == "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" + + def test_empty_both(self): + tray = {"tray_uuid": "", "tag_uid": ""} + assert _resolve_spool_tag(tray) == "" + + def test_missing_keys(self): + assert _resolve_spool_tag({}) == "" + + def test_zero_uuid_no_tag(self): + tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": ""} + assert _resolve_spool_tag(tray) == "" + + +class TestResolveGlobalTrayId: + """Tests for _resolve_global_tray_id().""" + + def test_default_mapping(self): + """slot 1 -> tray 0, slot 2 -> tray 1, etc.""" + assert _resolve_global_tray_id(1, None) == 0 + assert _resolve_global_tray_id(2, None) == 1 + assert _resolve_global_tray_id(4, None) == 3 + + def test_custom_mapping(self): + """Custom slot_to_tray overrides default.""" + mapping = [5, 2, -1, 0] + assert _resolve_global_tray_id(1, mapping) == 5 + assert _resolve_global_tray_id(2, mapping) == 2 + assert _resolve_global_tray_id(4, mapping) == 0 + + def test_unmapped_slot(self): + """Slot with -1 in mapping uses default.""" + mapping = [5, -1, 2, 0] + assert _resolve_global_tray_id(2, mapping) == 1 # default: slot 2 -> tray 1 + + def test_slot_beyond_mapping(self): + """Slot beyond mapping length uses default.""" + mapping = [5, 2] + assert _resolve_global_tray_id(3, mapping) == 2 # default: slot 3 -> tray 2 + + def test_empty_mapping(self): + mapping = [] + assert _resolve_global_tray_id(1, mapping) == 0 + + +class TestFallbackTagHelpers: + """Tests for frontend-mirrored fallback tag helpers.""" + + def test_hash_serial_matches_frontend_algorithm(self): + assert _hash_serial_to_hex32("01P00A000000000") == "ABA78457" + # Frontend trims and uppercases before hashing + assert _hash_serial_to_hex32(" 01p00a000000000 ") == "ABA78457" + + def test_global_tray_to_ams_slot_standard_ams(self): + assert _global_tray_id_to_ams_slot(0) == (0, 0) + assert _global_tray_id_to_ams_slot(7) == (1, 3) + + def test_global_tray_to_ams_slot_ams_ht(self): + assert _global_tray_id_to_ams_slot(128) == (128, 0) + assert _global_tray_id_to_ams_slot(135) == (135, 0) + + def test_global_tray_to_ams_slot_external(self): + assert _global_tray_id_to_ams_slot(254) == (255, 0) + assert _global_tray_id_to_ams_slot(255) == (255, 1) + + def test_get_fallback_spool_tag_standard(self): + assert _get_fallback_spool_tag("01P00A000000000", 5) == "ABA7845700010001" + + def test_get_fallback_spool_tag_ams_ht(self): + assert _get_fallback_spool_tag("01P00A000000000", 128) == "ABA7845700800000" + + def test_get_fallback_spool_tag_external(self): + assert _get_fallback_spool_tag("01P00A000000000", 255) == "ABA7845700FF0001" + + +class TestBuildAmsTrayLookup: + """Tests for build_ams_tray_lookup().""" + + def test_single_ams_unit(self): + raw = { + "ams": [ + { + "id": 0, + "tray": [ + {"id": 0, "tray_uuid": "AAA", "tag_uid": "111", "tray_type": "PLA"}, + {"id": 1, "tray_uuid": "BBB", "tag_uid": "222", "tray_type": "ABS"}, + ], + } + ] + } + lookup = build_ams_tray_lookup(raw) + assert lookup[0] == {"tray_uuid": "AAA", "tag_uid": "111", "tray_type": "PLA"} + assert lookup[1] == {"tray_uuid": "BBB", "tag_uid": "222", "tray_type": "ABS"} + + def test_multiple_ams_units(self): + raw = { + "ams": [ + {"id": 0, "tray": [{"id": 0, "tray_uuid": "A", "tag_uid": "", "tray_type": "PLA"}]}, + {"id": 1, "tray": [{"id": 0, "tray_uuid": "B", "tag_uid": "", "tray_type": "PETG"}]}, + ] + } + lookup = build_ams_tray_lookup(raw) + assert 0 in lookup # AMS 0, tray 0 + assert 4 in lookup # AMS 1, tray 0 (1*4+0) + assert lookup[4]["tray_uuid"] == "B" + + def test_external_spool(self): + raw = { + "ams": [], + "vt_tray": [{"tray_uuid": "EXT", "tag_uid": "X", "tray_type": "TPU"}], + } + lookup = build_ams_tray_lookup(raw) + assert 254 in lookup + assert lookup[254]["tray_type"] == "TPU" + + def test_empty_external_spool_skipped(self): + raw = {"ams": [], "vt_tray": [{"tray_type": ""}]} + lookup = build_ams_tray_lookup(raw) + assert 254 not in lookup + + def test_no_ams_data(self): + assert build_ams_tray_lookup({}) == {} + assert build_ams_tray_lookup({"ams": []}) == {} + + def test_missing_fields_default(self): + raw = {"ams": [{"id": 0, "tray": [{"id": 0}]}]} + lookup = build_ams_tray_lookup(raw) + assert lookup[0] == {"tray_uuid": "", "tag_uid": "", "tray_type": ""} + + +class TestStorePrintData: + """Tests for store_print_data().""" + + @pytest.mark.asyncio + async def test_prefers_explicit_ams_mapping_over_queue_mapping(self): + db = AsyncMock() + delete_result = MagicMock() + db.execute = AsyncMock(side_effect=[delete_result]) + db.add = MagicMock() + db.commit = AsyncMock() + + printer_manager = MagicMock() + printer_manager.get_status.return_value = SimpleNamespace( + raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA"}, {"id": 1, "tray_type": "PLA"}]}]} + ) + + mock_settings = MagicMock() + mock_path = MagicMock() + mock_path.exists.return_value = True + mock_settings.base_dir.__truediv__.return_value = mock_path + + with ( + patch("backend.app.services.spoolman_tracking.app_settings", mock_settings), + patch("backend.app.api.routes.settings.get_setting", AsyncMock(side_effect=["true", "true"])), + patch( + "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf", + return_value=[{"slot_id": 1, "used_g": 3.83, "type": "PLA", "color": "#FF0000"}], + ), + patch("backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf", return_value=None), + patch("backend.app.utils.threemf_tools.extract_filament_properties_from_3mf", return_value={}), + ): + await store_print_data( + printer_id=1, + archive_id=15, + file_path="archives/test.3mf", + db=db, + printer_manager=printer_manager, + ams_mapping=[1, -1, -1, -1], + ) + + db.add.assert_called_once() + tracking = db.add.call_args.args[0] + assert tracking.slot_to_tray == [1, -1, -1, -1] + db.execute.assert_called_once() diff --git a/frontend/src/__tests__/components/SpoolFormModal.test.tsx b/frontend/src/__tests__/components/SpoolFormModal.test.tsx index 9336a62c6..deb0a39d6 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -28,6 +28,11 @@ vi.mock('../../api/client', () => ({ createSpool: vi.fn().mockResolvedValue({ id: 99 }), updateSpool: vi.fn().mockResolvedValue({ id: 1 }), saveSpoolKProfiles: vi.fn().mockResolvedValue([]), + bulkCreateSpoolmanInventorySpools: vi.fn().mockResolvedValue({ + created: [{ id: 1, material: 'PLA' }], + requested_count: 1, + failed_count: 0, + }), }, })); @@ -415,6 +420,108 @@ describe('SpoolFormModal weightTouched', () => { expect((payload as { rgba: string }).rgba).toBe('FF0000FF'); }); + it('shows warning toast on partial bulk-create in Spoolman mode (T1/partial)', async () => { + vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({ + created: [{ id: 1, material: 'PLA' } as InventorySpool], + requested_count: 3, + failed_count: 2, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument(); + }); + + // Enable Quick Add mode so the quantity field appears + const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]'); + const toggleButton = quickAddRow?.querySelector('button[type="button"]'); + expect(toggleButton).toBeTruthy(); + fireEvent.click(toggleButton!); + + // Set quantity to 3 (triggers bulkCreateMutation instead of createMutation) + const quantityContainer = screen.getByText('Quantity').closest('div'); + const quantityInput = quantityContainer?.querySelector('input[type="number"]'); + expect(quantityInput).toBeTruthy(); + fireEvent.change(quantityInput!, { target: { value: '3' } }); + + // Click the submit button + const addButtons = screen.getAllByRole('button', { name: /add spool/i }); + const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save')); + expect(submitButton).toBeTruthy(); + fireEvent.click(submitButton!); + + await waitFor(() => { + expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1); + }); + + // Should show a warning toast for partial failure (1 created, 2 failed, 3 requested) + expect(mockShowToast).toHaveBeenCalledWith( + expect.stringContaining('1 of 3'), + 'warning', + ); + }); + + it('shows success toast on full bulk-create success in Spoolman mode (T1/success)', async () => { + vi.mocked(api.bulkCreateSpoolmanInventorySpools).mockResolvedValueOnce({ + created: [ + { id: 1, material: 'PLA' } as InventorySpool, + { id: 2, material: 'PLA' } as InventorySpool, + { id: 3, material: 'PLA' } as InventorySpool, + ], + requested_count: 3, + failed_count: 0, + }); + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument(); + }); + + // Enable Quick Add mode so the quantity field appears + const quickAddRow = screen.getByText('Quick Add (Stock)').closest('div[class*="justify-between"]'); + const toggleButton = quickAddRow?.querySelector('button[type="button"]'); + expect(toggleButton).toBeTruthy(); + fireEvent.click(toggleButton!); + + // Set quantity to 3 + const quantityContainer = screen.getByText('Quantity').closest('div'); + const quantityInput = quantityContainer?.querySelector('input[type="number"]'); + expect(quantityInput).toBeTruthy(); + fireEvent.change(quantityInput!, { target: { value: '3' } }); + + // Click the submit button + const addButtons = screen.getAllByRole('button', { name: /add spool/i }); + const submitButton = addButtons.find(btn => btn.tagName === 'BUTTON' && btn.querySelector('svg.lucide-save')); + expect(submitButton).toBeTruthy(); + fireEvent.click(submitButton!); + + await waitFor(() => { + expect(api.bulkCreateSpoolmanInventorySpools).toHaveBeenCalledTimes(1); + }); + + // Should show a success toast listing the count of created spools + expect(mockShowToast).toHaveBeenCalledWith( + expect.stringContaining('3'), + 'success', + ); + }); + it('displays correct catalog name when duplicates exist', async () => { const spoolWithCatalogId: InventorySpool = { ...existingSpool, diff --git a/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx b/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx new file mode 100644 index 000000000..c78edfb26 --- /dev/null +++ b/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx @@ -0,0 +1,233 @@ +/** + * Tests for the ?spool= deep-link flow in InventoryPage. + * + * Three scenarios: + * 1. Spool is already in the loaded list → modal opens immediately. + * 2. Spool is not in list → targeted API fetch succeeds → modal opens. + * 3. Spool is not found (404) → error toast shown, param removed from URL. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import InventoryPageRouter from '../../pages/InventoryPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +// Minimal spool fixture shared across scenarios +const BASE_SPOOL = { + id: 42, + material: 'PLA', + subtype: 'Basic', + brand: 'Bambu Lab', + color_name: 'Red', + rgba: 'FF0000FF', + label_weight: 1000, + core_weight: 250, + weight_used: 100, + slicer_filament: null, + slicer_filament_name: null, + nozzle_temp_min: 220, + nozzle_temp_max: 240, + note: null, + added_full: null, + last_used: null, + encode_time: null, + tag_uid: null, + tray_uuid: null, + data_origin: null, + tag_type: null, + archived_at: null, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + k_profiles: [], + cost_per_kg: null, + last_scale_weight: null, + last_weighed_at: null, + storage_location: null, + weight_locked: false, +}; + +const MOCK_SETTINGS = { + auto_archive: false, + save_thumbnails: false, + capture_finish_photo: false, + default_filament_cost: 25.0, + currency: 'USD', + energy_cost_per_kwh: 0.15, + energy_tracking_mode: 'total', + spoolman_enabled: false, + spoolman_url: '', + spoolman_sync_mode: 'auto', + spoolman_disable_weight_sync: false, + spoolman_report_partial_usage: true, + check_updates: false, + check_printer_firmware: false, + include_beta_updates: false, + language: 'en', + notification_language: 'en', + bed_cooled_threshold: 35, + ams_humidity_good: 40, + ams_humidity_fair: 60, + ams_temp_good: 28, + ams_temp_fair: 35, + ams_history_retention_days: 30, + per_printer_mapping_expanded: false, + date_format: 'system', + time_format: 'system', + default_printer_id: null, + virtual_printer_enabled: false, + virtual_printer_access_code: '', + virtual_printer_mode: 'immediate', + dark_style: 'classic', + dark_background: 'neutral', + dark_accent: 'green', + light_style: 'classic', + light_background: 'neutral', + light_accent: 'green', + ftp_retry_enabled: true, + ftp_retry_count: 3, + ftp_retry_delay: 2, + ftp_timeout: 30, + mqtt_enabled: false, + mqtt_broker: '', + mqtt_port: 1883, + mqtt_username: '', + mqtt_password: '', + mqtt_topic_prefix: 'bambuddy', + mqtt_use_tls: false, + external_url: '', + ha_enabled: false, + ha_url: '', + ha_token: '', + ha_url_from_env: false, + ha_token_from_env: false, + ha_env_managed: false, + library_archive_mode: 'ask', + library_disk_warning_gb: 5.0, + camera_view_mode: 'window', + preferred_slicer: 'bambu_studio', + prometheus_enabled: false, + prometheus_token: '', + low_stock_threshold: 20.0, +}; + +function setupCommonHandlers(spoolList: object[]) { + server.use( + http.get('/api/v1/settings/', () => HttpResponse.json(MOCK_SETTINGS)), + // getSpoolmanSettings calls /api/v1/settings/spoolman (not /api/v1/spoolman/settings) + http.get('/api/v1/settings/spoolman', () => + HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' }) + ), + http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolList)), + http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])), + http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])), + ); +} + +describe('InventoryPage - deep-link ?spool= flow', () => { + const originalLocation = window.location.href; + + afterEach(() => { + // Restore URL after each test + window.history.replaceState({}, '', originalLocation); + }); + + describe('scenario 1: spool is already in the loaded list', () => { + beforeEach(() => { + window.history.pushState({}, '', '/?spool=42'); + setupCommonHandlers([BASE_SPOOL]); + }); + + it('removes ?spool= param from URL after handling', async () => { + render(); + + await waitFor(() => { + expect(window.location.search).not.toContain('spool=42'); + }); + }); + + it('opens the edit modal for the linked spool', async () => { + render(); + + // The SpoolFormModal should open — it renders material name in a heading or field + await waitFor(() => { + // Modal is open when the spool form inputs are visible + expect(screen.getAllByText(/PLA/i).length).toBeGreaterThan(0); + }); + }); + }); + + describe('scenario 2: targeted fetch (spool not in initial list)', () => { + beforeEach(() => { + window.history.pushState({}, '', '/?spool=42'); + // List is empty; single-spool fetch returns the spool + setupCommonHandlers([]); + server.use( + http.get('/api/v1/inventory/spools/:id', ({ params }) => { + if (Number(params.id) === 42) { + return HttpResponse.json(BASE_SPOOL); + } + return HttpResponse.json({ detail: 'Not found' }, { status: 404 }); + }) + ); + }); + + it('removes ?spool= param from URL after successful targeted fetch', async () => { + render(); + + await waitFor(() => { + expect(window.location.search).not.toContain('spool=42'); + }); + }); + }); + + describe('scenario 3: spool not found (404)', () => { + beforeEach(() => { + window.history.pushState({}, '', '/?spool=9999'); + setupCommonHandlers([]); + server.use( + http.get('/api/v1/inventory/spools/:id', () => + HttpResponse.json({ detail: 'Not found' }, { status: 404 }) + ) + ); + }); + + it('removes ?spool= param from URL on 404', async () => { + render(); + + await waitFor(() => { + expect(window.location.search).not.toContain('spool=9999'); + }); + }); + + it('shows an error notification when spool is not found', async () => { + render(); + + // Must render the exact i18n string for deepLinkSpoolNotFound (en: 'Spool not found'). + // Using findByText fails the test if the toast is absent or uses the wrong key. + await screen.findByText('Spool not found'); + }); + }); + + describe('scenario 4: targeted fetch returns 5xx server error', () => { + beforeEach(() => { + window.history.pushState({}, '', '/?spool=42'); + setupCommonHandlers([]); + server.use( + http.get('/api/v1/inventory/spools/:id', () => + HttpResponse.json({ detail: 'Internal Server Error' }, { status: 500 }) + ) + ); + }); + + it('shows the deepLinkFetchFailed toast on 5xx', async () => { + render(); + + // The deep-link query has a custom retry callback (up to 2 retries with + // exponential backoff) that overrides the test QueryClient's retry:false. + // Allow up to 6 s for the retries to exhaust before the toast appears. + await screen.findByText('Could not load spool — try again', {}, { timeout: 6000 }); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/InventoryPageSearch.test.ts b/frontend/src/__tests__/pages/InventoryPageSearch.test.ts new file mode 100644 index 000000000..fccecdc70 --- /dev/null +++ b/frontend/src/__tests__/pages/InventoryPageSearch.test.ts @@ -0,0 +1,175 @@ +/** + * Unit tests for the global search filter used in InventoryPage. + * + * The filter is a pure client-side computation — replicated inline here + * following the same pattern as InventoryPageGrouping.test.ts so no DOM + * render or API mock is needed. + * + * Fields covered: brand, material, color_name, subtype, note, + * slicer_filament_name, storage_location. + */ + +import { describe, it, expect } from 'vitest'; +import type { InventorySpool } from '../../api/client'; + +// Replicate the search filter from InventoryPage +function applySearch(spools: InventorySpool[], search: string): InventorySpool[] { + if (!search) return spools; + const q = search.toLowerCase(); + return spools.filter((s) => + s.brand?.toLowerCase().includes(q) || + s.material.toLowerCase().includes(q) || + s.color_name?.toLowerCase().includes(q) || + s.subtype?.toLowerCase().includes(q) || + s.note?.toLowerCase().includes(q) || + s.slicer_filament_name?.toLowerCase().includes(q) || + s.storage_location?.toLowerCase().includes(q) + ); +} + +function makeSpool(overrides: Partial & { id: number }): InventorySpool { + return { + material: 'PLA', + subtype: 'Basic', + brand: 'Bambu Lab', + color_name: 'White', + rgba: 'FFFFFFFF', + label_weight: 1000, + core_weight: 250, + core_weight_catalog_id: null, + weight_used: 0, + weight_locked: false, + slicer_filament: null, + slicer_filament_name: null, + nozzle_temp_min: null, + nozzle_temp_max: null, + note: null, + added_full: null, + last_used: null, + encode_time: null, + tag_uid: null, + tray_uuid: null, + data_origin: null, + tag_type: null, + archived_at: null, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + k_profiles: [], + cost_per_kg: null, + last_scale_weight: null, + last_weighed_at: null, + storage_location: null, + ...overrides, + }; +} + +describe('InventoryPage search filter', () => { + describe('storage_location', () => { + it('returns spools whose storage_location matches the query', () => { + const spools = [ + makeSpool({ id: 1, storage_location: 'IKEA Regal' }), + makeSpool({ id: 2, storage_location: 'Kiste - PLA' }), + makeSpool({ id: 3, storage_location: 'Lagerregal' }), + ]; + expect(applySearch(spools, 'IKEA').map((s) => s.id)).toEqual([1]); + expect(applySearch(spools, 'Kiste').map((s) => s.id)).toEqual([2]); + expect(applySearch(spools, 'regal').map((s) => s.id)).toEqual([1, 3]); + }); + + it('is case-insensitive for storage_location', () => { + const spools = [makeSpool({ id: 1, storage_location: 'IKEA Regal' })]; + expect(applySearch(spools, 'ikea regal')).toHaveLength(1); + expect(applySearch(spools, 'IKEA REGAL')).toHaveLength(1); + expect(applySearch(spools, 'ikEa')).toHaveLength(1); + }); + + it('matches partial storage_location strings', () => { + const spools = [makeSpool({ id: 1, storage_location: 'Kiste - PLA' })]; + expect(applySearch(spools, 'Kis')).toHaveLength(1); + expect(applySearch(spools, 'PLA')).toHaveLength(1); + expect(applySearch(spools, '- PLA')).toHaveLength(1); + }); + + it('does not crash when storage_location is null', () => { + const spools = [makeSpool({ id: 1, storage_location: null })]; + expect(() => applySearch(spools, 'regal')).not.toThrow(); + expect(applySearch(spools, 'regal')).toHaveLength(0); + }); + + it('excludes spools whose storage_location does not match', () => { + const spools = [ + makeSpool({ id: 1, storage_location: 'IKEA Regal' }), + makeSpool({ id: 2, storage_location: 'Kiste - PLA' }), + ]; + expect(applySearch(spools, 'IKEA').map((s) => s.id)).toEqual([1]); + }); + }); + + describe('existing fields (regression)', () => { + it('still finds by brand', () => { + const spools = [ + makeSpool({ id: 1, brand: 'Bambu Lab' }), + makeSpool({ id: 2, brand: 'Polymaker' }), + ]; + expect(applySearch(spools, 'polymaker').map((s) => s.id)).toEqual([2]); + }); + + it('still finds by material', () => { + const spools = [ + makeSpool({ id: 1, material: 'PLA' }), + makeSpool({ id: 2, material: 'PETG' }), + ]; + expect(applySearch(spools, 'petg').map((s) => s.id)).toEqual([2]); + }); + + it('still finds by color_name', () => { + const spools = [ + makeSpool({ id: 1, color_name: 'Jade White' }), + makeSpool({ id: 2, color_name: 'Black' }), + ]; + expect(applySearch(spools, 'jade').map((s) => s.id)).toEqual([1]); + }); + + it('still finds by note', () => { + const spools = [ + makeSpool({ id: 1, note: 'fast print only' }), + makeSpool({ id: 2, note: null }), + ]; + expect(applySearch(spools, 'fast').map((s) => s.id)).toEqual([1]); + }); + + it('returns all spools when search is empty', () => { + const spools = [makeSpool({ id: 1 }), makeSpool({ id: 2 })]; + expect(applySearch(spools, '')).toHaveLength(2); + }); + + it('returns empty array when nothing matches', () => { + const spools = [makeSpool({ id: 1, brand: 'Bambu Lab', material: 'PLA' })]; + expect(applySearch(spools, 'xxxxxxxx')).toHaveLength(0); + }); + }); + + describe('cross-field matching', () => { + it('matches a spool if any field contains the query', () => { + const spool = makeSpool({ + id: 1, + brand: 'Bambu Lab', + material: 'PLA', + storage_location: 'IKEA Regal', + }); + // Each individual field matches + expect(applySearch([spool], 'Bambu')).toHaveLength(1); + expect(applySearch([spool], 'PLA')).toHaveLength(1); + expect(applySearch([spool], 'IKEA')).toHaveLength(1); + }); + + it('a query matching only storage_location is found even when other fields do not match', () => { + const spools = [ + makeSpool({ id: 1, brand: 'Polymaker', material: 'PETG', storage_location: 'IKEA Regal' }), + makeSpool({ id: 2, brand: 'Polymaker', material: 'PETG', storage_location: 'Kiste' }), + ]; + const result = applySearch(spools, 'IKEA'); + expect(result.map((s) => s.id)).toEqual([1]); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx index 63a36537a..6bcaffed7 100644 --- a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx +++ b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx @@ -13,7 +13,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter, Route, Routes, Outlet } from 'react-router-dom'; +import { ToastProvider } from '../../contexts/ToastContext'; import { SpoolBuddyWriteTagPage } from '../../pages/spoolbuddy/SpoolBuddyWriteTagPage'; +import { api as mockedApi, spoolbuddyApi as mockedSpoolbuddyApi } from '../../api/client'; // Mock the API modules vi.mock('../../api/client', () => ({ @@ -68,13 +70,15 @@ function renderPage() { return render( - - - }> - } /> - - - + + + + }> + } /> + + + + ); } @@ -134,4 +138,49 @@ describe('SpoolBuddyWriteTagPage', () => { expect(screen.getByText('Select a spool, then place a blank NTAG on the reader')).toBeDefined(); mockOutletContext.sbState.deviceOnline = false; // reset }); + + it('shows one toast per warning when writeTag returns multiple warnings (B5 / T2)', async () => { + const spool = { + id: 99, + material: 'PLA', + label_weight: 1000, + weight_used: 0, + tag_uid: null, + tray_uuid: null, + archived_at: null, + rgba: 'FF0000FF', + }; + vi.mocked(mockedApi.getSpools).mockResolvedValue([spool as never]); + vi.mocked(mockedSpoolbuddyApi.getDevices).mockResolvedValue([ + { device_id: 'sb-test', hostname: 'sb-test.local' } as never, + ]); + vi.mocked(mockedSpoolbuddyApi.writeTag).mockResolvedValueOnce({ + status: 'queued', + warnings: ['color_name not set', 'nozzle_temp_min not set'], + } as never); + + mockOutletContext.sbState.deviceOnline = true; + renderPage(); + + // Wait for the spool to appear and click it to select it + await waitFor(() => { + expect(screen.getByText('PLA')).toBeDefined(); + }); + fireEvent.click(screen.getByText('PLA')); + + // Click the Write Tag button + await waitFor(() => { + const writeBtn = screen.getByText('Write Tag'); + expect((writeBtn as HTMLButtonElement).disabled).toBe(false); + }); + fireEvent.click(screen.getByText('Write Tag')); + + // Both warning messages must appear as separate toasts + await waitFor(() => { + expect(screen.getByText('color_name not set')).toBeDefined(); + expect(screen.getByText('nozzle_temp_min not set')).toBeDefined(); + }); + + mockOutletContext.sbState.deviceOnline = false; // reset + }); }); diff --git a/frontend/src/__tests__/utils/spoolFormValidation.test.ts b/frontend/src/__tests__/utils/spoolFormValidation.test.ts new file mode 100644 index 000000000..9e85e6aab --- /dev/null +++ b/frontend/src/__tests__/utils/spoolFormValidation.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { validateForm, defaultFormData } from '../../components/spool-form/types'; + +describe('validateForm', () => { + describe('standard mode', () => { + it('requires slicer_filament, material, brand, and subtype', () => { + const result = validateForm(defaultFormData); + expect(result.isValid).toBe(false); + expect(result.errors.slicer_filament).toBeDefined(); + expect(result.errors.material).toBeDefined(); + expect(result.errors.brand).toBeDefined(); + expect(result.errors.subtype).toBeDefined(); + }); + + it('passes when all required fields are filled', () => { + const data = { + ...defaultFormData, + slicer_filament: 'Bambu PLA Basic @BBL', + material: 'PLA', + brand: 'Bambu Lab', + subtype: 'Basic', + }; + const result = validateForm(data); + expect(result.isValid).toBe(true); + expect(Object.keys(result.errors)).toHaveLength(0); + }); + }); + + describe('quickAdd mode', () => { + it('only requires material', () => { + const result = validateForm(defaultFormData, true); + expect(result.isValid).toBe(false); + expect(result.errors.material).toBeDefined(); + expect(result.errors.slicer_filament).toBeUndefined(); + expect(result.errors.brand).toBeUndefined(); + }); + + it('passes with only material set', () => { + const data = { ...defaultFormData, material: 'PETG' }; + const result = validateForm(data, true); + expect(result.isValid).toBe(true); + }); + }); + + describe('spoolmanMode', () => { + it('only requires material (same as quickAdd)', () => { + const result = validateForm(defaultFormData, false, true); + expect(result.isValid).toBe(false); + expect(result.errors.material).toBeDefined(); + expect(result.errors.slicer_filament).toBeUndefined(); + expect(result.errors.brand).toBeUndefined(); + expect(result.errors.subtype).toBeUndefined(); + }); + + it('passes with only material set', () => { + const data = { ...defaultFormData, material: 'PLA' }; + const result = validateForm(data, false, true); + expect(result.isValid).toBe(true); + expect(Object.keys(result.errors)).toHaveLength(0); + }); + + it('does not require slicer_filament even when present', () => { + const data = { ...defaultFormData, material: 'ABS' }; + const result = validateForm(data, false, true); + expect(result.isValid).toBe(true); + }); + + it('fails when material is empty string', () => { + const data = { ...defaultFormData, material: '' }; + const result = validateForm(data, false, true); + expect(result.isValid).toBe(false); + expect(result.errors.material).toBeDefined(); + }); + + it('quickAdd takes precedence over spoolmanMode', () => { + const data = { ...defaultFormData, material: 'PLA' }; + const result = validateForm(data, true, true); + expect(result.isValid).toBe(true); + }); + }); +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bdae1406e..960d16ad4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,6 +2,15 @@ import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/ const API_BASE = '/api/v1'; +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + } +} + // Auth token storage // By default tokens are stored in sessionStorage (tab-scoped, cleared on close). // When the token originates from the ?token= URL param (kiosk bootstrap), it is @@ -100,7 +109,7 @@ async function request( } } - throw new Error(message); + throw new ApiError(message, response.status); } // Handle empty responses (204 No Content, etc.) @@ -2112,6 +2121,13 @@ export interface InventorySpool { last_scale_weight: number | null; last_weighed_at: string | null; k_profiles?: SpoolKProfile[]; + storage_location?: string | null; +} + +export interface SpoolmanBulkCreateResult { + created: InventorySpool[]; + requested_count: number; + failed_count: number; } export interface SpoolUsageRecord { @@ -4217,6 +4233,44 @@ export const api = { getFilamentPresets: () => request('/cloud/filaments'), + // Spoolman Inventory proxy (unified UI when Spoolman is enabled) + getSpoolmanInventorySpools: (includeArchived = false) => + request(`/spoolman/inventory/spools?include_archived=${includeArchived}`), + getSpoolmanInventorySpool: (id: number) => + request(`/spoolman/inventory/spools/${id}`), + createSpoolmanInventorySpool: (data: Omit) => + request('/spoolman/inventory/spools', { + method: 'POST', + body: JSON.stringify(data), + }), + bulkCreateSpoolmanInventorySpools: ( + data: Omit, + quantity: number, + ) => + request('/spoolman/inventory/spools/bulk', { + method: 'POST', + body: JSON.stringify({ spool: data, quantity }), + }), + updateSpoolmanInventorySpool: ( + id: number, + data: Partial>, + ) => + request(`/spoolman/inventory/spools/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteSpoolmanInventorySpool: (id: number) => + request<{ status: string }>(`/spoolman/inventory/spools/${id}`, { method: 'DELETE' }), + archiveSpoolmanInventorySpool: (id: number) => + request(`/spoolman/inventory/spools/${id}/archive`, { method: 'POST' }), + restoreSpoolmanInventorySpool: (id: number) => + request(`/spoolman/inventory/spools/${id}/restore`, { method: 'POST' }), + syncSpoolmanSpoolWeight: (spoolId: number, weightGrams: number) => + request<{ status: string; weight_used: number }>(`/spoolman/inventory/spools/${spoolId}/weight`, { + method: 'PATCH', + body: JSON.stringify({ weight_grams: weightGrams }), + }), + // Updates getVersion: () => request('/updates/version'), checkForUpdates: () => request('/updates/check'), @@ -5745,7 +5799,7 @@ export const spoolbuddyApi = { request<{ public_key: string }>('/spoolbuddy/ssh/public-key'), writeTag: (deviceId: string, spoolId: number) => - request<{ status: string }>('/spoolbuddy/nfc/write-tag', { + request<{ status: string; warnings?: string[] }>('/spoolbuddy/nfc/write-tag', { method: 'POST', body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }), }), diff --git a/frontend/src/components/FilamentHoverCard.tsx b/frontend/src/components/FilamentHoverCard.tsx index 7473b380c..bad5276f6 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect, type ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react'; +import { Droplets, Link2, Copy, Check, Settings2, Package, Unlink } from 'lucide-react'; import { isLightColor } from '../utils/colors'; interface FilamentData { @@ -51,6 +52,7 @@ interface FilamentHoverCardProps { */ export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, inventory, configureSlot }: FilamentHoverCardProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState<'top' | 'bottom'>('top'); const [copied, setCopied] = useState(false); @@ -291,20 +293,20 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp )} - {/* Open in Spoolman button (when already linked) */} - {spoolman.linkedSpoolId && spoolman.spoolmanUrl && ( + {/* Open in inventory button (when already linked to a Spoolman spool) */} + {spoolman.linkedSpoolId && ( <> - e.stopPropagation()} + {spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && ( {inventory.onUnassignSpool && ( - {!quickAdd && ( + {!quickAdd && !spoolmanMode && (