From 9e938cbc8cc8cd10481394be06fc24a7e676c1ae Mon Sep 17 00:00:00 2001 From: maziggy Date: Fri, 24 Apr 2026 14:33:33 +0200 Subject: [PATCH] Revert "feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)" This reverts commit 89f14c57ad77338e6418ce636104b4028ce4cdfb. --- CHANGELOG.md | 17 +- backend/app/api/routes/_spoolman_helpers.py | 222 --- backend/app/api/routes/spoolbuddy.py | 449 +---- backend/app/api/routes/spoolman_inventory.py | 531 ------ backend/app/core/auth.py | 46 +- backend/app/core/database.py | 10 - backend/app/main.py | 2 - backend/app/models/spool.py | 4 +- backend/app/models/spoolbuddy_device.py | 1 - backend/app/schemas/spool.py | 8 +- backend/app/schemas/spoolbuddy.py | 78 +- backend/app/services/opentag3d.py | 86 +- backend/app/services/spoolbuddy_ssh.py | 110 +- backend/app/services/spoolman.py | 326 +--- backend/app/services/spoolman_tracking.py | 12 +- .../integration/test_auth_apikey_rbac.py | 160 -- backend/tests/integration/test_spoolbuddy.py | 1101 +----------- .../test_spoolman_inventory_api.py | 1524 ----------------- .../{ => services}/test_spoolbuddy_ssh.py | 251 +-- .../unit/services/test_spoolman_service.py | 49 +- .../unit/services/test_spoolman_tracking.py | 10 +- backend/tests/unit/test_opentag3d.py | 179 -- .../unit/test_spoolbuddy_schema_validation.py | 197 --- .../unit/test_spoolman_inventory_helpers.py | 246 --- .../unit/test_spoolman_inventory_methods.py | 424 ----- backend/tests/unit/test_spoolman_tracking.py | 223 --- .../components/SpoolFormModal.test.tsx | 107 -- .../pages/InventoryPageDeepLink.test.tsx | 233 --- .../pages/InventoryPageSearch.test.ts | 175 -- .../pages/SpoolBuddyWriteTagPage.test.tsx | 63 +- .../utils/spoolFormValidation.test.ts | 81 - frontend/src/api/client.ts | 58 +- frontend/src/components/FilamentHoverCard.tsx | 37 +- frontend/src/components/SpoolFormModal.tsx | 133 +- .../spool-form/AdditionalSection.tsx | 13 - frontend/src/components/spool-form/types.ts | 11 +- frontend/src/i18n/locales/de.ts | 20 - frontend/src/i18n/locales/en.ts | 20 - frontend/src/i18n/locales/fr.ts | 20 - frontend/src/i18n/locales/it.ts | 20 - frontend/src/i18n/locales/ja.ts | 20 - frontend/src/i18n/locales/pt-BR.ts | 20 - frontend/src/i18n/locales/zh-CN.ts | 20 - frontend/src/i18n/locales/zh-TW.ts | 20 - frontend/src/pages/InventoryPage.tsx | 216 +-- .../spoolbuddy/SpoolBuddyInventoryPage.tsx | 29 +- .../spoolbuddy/SpoolBuddyWriteTagPage.tsx | 9 +- 47 files changed, 343 insertions(+), 7248 deletions(-) delete mode 100644 backend/app/api/routes/_spoolman_helpers.py delete mode 100644 backend/app/api/routes/spoolman_inventory.py delete mode 100644 backend/tests/integration/test_auth_apikey_rbac.py delete mode 100644 backend/tests/integration/test_spoolman_inventory_api.py rename backend/tests/unit/{ => services}/test_spoolbuddy_ssh.py (60%) delete mode 100644 backend/tests/unit/test_spoolbuddy_schema_validation.py delete mode 100644 backend/tests/unit/test_spoolman_inventory_helpers.py delete mode 100644 backend/tests/unit/test_spoolman_inventory_methods.py delete mode 100644 backend/tests/unit/test_spoolman_tracking.py delete mode 100644 frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx delete mode 100644 frontend/src/__tests__/pages/InventoryPageSearch.test.ts delete mode 100644 frontend/src/__tests__/utils/spoolFormValidation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbfa2e43..8e21bb55f 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] - 2026-04-22 +## [0.2.3.2] - 2020-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,20 +60,7 @@ 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 -### 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 +## [0.2.3.1] - 2020-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 deleted file mode 100644 index f71a84a09..000000000 --- a/backend/app/api/routes/_spoolman_helpers.py +++ /dev/null @@ -1,222 +0,0 @@ -"""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 1242e875a..58aeed615 100644 --- a/backend/app/api/routes/spoolbuddy.py +++ b/backend/app/api/routes/spoolbuddy.py @@ -7,7 +7,6 @@ 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 @@ -35,12 +34,10 @@ 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__) @@ -52,45 +49,6 @@ _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 @@ -213,8 +171,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 as exc: - logger.warning("Could not attach SSH public key to heartbeat response: %s", exc) + except Exception: + pass # Key not generated yet — daemon can still work without it return response @@ -363,89 +321,28 @@ async def nfc_tag_scanned( }, } ) - logger.info("SpoolBuddy tag matched (local): %s -> spool %d", req.tag_uid, spool.id) - return {"status": "ok", "matched": True, "spool_id": spool.id} + 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, + ) - # 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} + return {"status": "ok", "matched": spool is not None, "spool_id": spool.id if spool else None} @router.post("/nfc/tag-removed") @@ -471,95 +368,38 @@ async def nfc_write_tag( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ): """Queue an NFC tag write command for a SpoolBuddy device.""" - from backend.app.models.spool import Spool - from backend.app.services.opentag3d import encode_opentag3d, encode_opentag3d_from_mapped + import json - # Find the device first (required regardless of spool source) + from backend.app.models.spool import Spool + from backend.app.services.opentag3d import encode_opentag3d + + # 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 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") - # 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), - ) + # Encode OpenTag3D NDEF data + ndef_data = encode_opentag3d(spool) # Store write payload and set pending command device.pending_write_payload = json.dumps( { - "spool_id": req.spool_id, + "spool_id": 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 (%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 + logger.info("Write tag queued for device %s, spool %d (%d bytes)", req.device_id, spool.id, len(ndef_data)) + return {"status": "queued"} @router.post("/nfc/write-result") @@ -569,137 +409,37 @@ async def nfc_write_result( _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ): """Handle NFC tag write result from SpoolBuddy daemon.""" - # Find the device + # Find the device and clear pending state 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: - 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"} + # Link the tag to the 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: 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( @@ -764,47 +504,10 @@ 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 - db_result = await db.execute(select(Spool).where(Spool.id == req.spool_id)) - spool = db_result.scalar_one_or_none() + 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") @@ -1256,14 +959,13 @@ async def get_ssh_public_key( key = await get_public_key() return {"public_key": key} except Exception as 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 + raise HTTPException(status_code=500, detail=f"Failed to get SSH key: {e}") from e @router.post("/devices/{device_id}/update-status") async def report_update_status( device_id: str, - req: UpdateStatusRequest, + req: dict, db: AsyncSession = Depends(get_db), _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE), ): @@ -1273,24 +975,25 @@ async def report_update_status( if not device: raise HTTPException(status_code=404, detail="Device not registered") - 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() + status = req.get("status", "") + message = req.get("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, - } - ) + 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, + } + ) return {"status": "ok"} diff --git a/backend/app/api/routes/spoolman_inventory.py b/backend/app/api/routes/spoolman_inventory.py deleted file mode 100644 index 75b38044c..000000000 --- a/backend/app/api/routes/spoolman_inventory.py +++ /dev/null @@ -1,531 +0,0 @@ -"""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 f7297e5f2..128b8f2a5 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -25,45 +25,6 @@ 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 @@ -670,7 +631,8 @@ async def get_api_key( detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer '", ) - # Pre-filter by key_prefix to avoid O(n) pbkdf2 hashes across all enabled keys. + # 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). key_lookup = api_key_value[:8] if len(api_key_value) >= 8 else api_key_value result = await db.execute( select(APIKey).where( @@ -794,7 +756,6 @@ 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( @@ -811,7 +772,6 @@ 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, @@ -877,7 +837,6 @@ 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) @@ -887,7 +846,6 @@ 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 5d158e420..5cc855907 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1107,13 +1107,6 @@ 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 @@ -1213,9 +1206,6 @@ 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 1dcad5565..a44462b0f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,7 +50,6 @@ from backend.app.api.routes import ( smart_plugs, spoolbuddy, spoolman, - spoolman_inventory, support, system, updates, @@ -4544,7 +4543,6 @@ 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 2e620bc1e..91c79df45 100644 --- a/backend/app/models/spool.py +++ b/backend/app/models/spool.py @@ -36,11 +36,9 @@ 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(32)) # RFID tag UID (up to 32 hex chars) + tag_uid: Mapped[str | None] = mapped_column(String(16)) # RFID tag UID (16 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 c6a22da03..14366ecf7 100644 --- a/backend/app/models/spoolbuddy_device.py +++ b/backend/app/models/spoolbuddy_device.py @@ -37,6 +37,5 @@ 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 abfe2fc18..df63fe8a8 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 = Field(default=None, max_length=32) + tag_uid: str | None = None tray_uuid: str | None = None data_origin: str | None = None tag_type: str | None = None @@ -26,7 +26,6 @@ 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): @@ -53,13 +52,12 @@ class SpoolUpdate(BaseModel): nozzle_temp_min: int | None = None nozzle_temp_max: int | None = None note: str | None = None - tag_uid: str | None = Field(default=None, max_length=32) + tag_uid: str | None = None 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): @@ -92,7 +90,7 @@ class SpoolResponse(SpoolBase): added_full: bool | None = None last_used: datetime | None = None encode_time: datetime | None = None - tag_uid: str | None = Field(default=None, max_length=32) + tag_uid: str | None = None 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 e5263b8da..87044385e 100644 --- a/backend/app/schemas/spoolbuddy.py +++ b/backend/app/schemas/spoolbuddy.py @@ -1,8 +1,6 @@ -import json from datetime import datetime -from typing import Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field # --- Device schemas --- @@ -11,14 +9,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 = Field(None, max_length=20) + firmware_version: str | None = None has_nfc: bool = True has_scale: bool = True tare_offset: int = 0 calibration_factor: float = 1.0 - 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) + nfc_reader_type: str | None = None + nfc_connection: str | None = None + backend_url: str | None = None has_backlight: bool = False @@ -60,20 +58,13 @@ class HeartbeatRequest(BaseModel): nfc_ok: bool = False scale_ok: bool = False uptime_s: int = 0 - 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) + 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 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 @@ -89,31 +80,31 @@ class HeartbeatResponse(BaseModel): class TagScannedRequest(BaseModel): - 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]*$") + device_id: str + tag_uid: str + tray_uuid: str | None = None sak: int | None = None - tag_type: str | None = Field(None, max_length=50) + tag_type: str | None = None raw_blocks: dict | None = None class TagRemovedRequest(BaseModel): - device_id: str = Field(..., max_length=50) - tag_uid: str = Field(..., max_length=32) + device_id: str + tag_uid: str # --- Scale schemas --- class ScaleReadingRequest(BaseModel): - device_id: str = Field(..., max_length=50) - weight_grams: float = Field(..., ge=0.0, le=100_000.0) + device_id: str + weight_grams: float stable: bool = False raw_adc: int | None = None class UpdateSpoolWeightRequest(BaseModel): - spool_id: int = Field(..., gt=0) + spool_id: int weight_grams: float @@ -139,16 +130,16 @@ class CalibrationResponse(BaseModel): class WriteTagRequest(BaseModel): - device_id: str = Field(..., max_length=50) - spool_id: int = Field(..., gt=0) + device_id: str + spool_id: int class WriteTagResultRequest(BaseModel): - 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]+$") + device_id: str + spool_id: int + tag_uid: str success: bool - message: str | None = Field(None, max_length=500) + message: str | None = None class DisplaySettingsRequest(BaseModel): @@ -162,27 +153,20 @@ class SystemConfigRequest(BaseModel): class SystemCommandRequest(BaseModel): - command: str = Field( - ..., max_length=50, description="System command: reboot, shutdown, restart_daemon, restart_browser" - ) + command: str = Field(..., description="System command: reboot, shutdown, restart_daemon, restart_browser") class SystemCommandResultRequest(BaseModel): - command: str = Field(..., max_length=50) + command: str success: bool - message: str | None = Field(None, max_length=500) - - -class UpdateStatusRequest(BaseModel): - status: Literal["updating", "complete", "error"] - message: str | None = Field(None, max_length=255) + message: str | None = None # --- Diagnostics schemas --- class DiagnosticResultRequest(BaseModel): - diagnostic: str = Field(..., max_length=50, description="Diagnostic type: 'nfc', 'scale', or 'read_tag'") + diagnostic: str # 'nfc', 'scale', or 'read_tag' success: bool - output: str = Field(..., max_length=10_000) - exit_code: int = Field(..., ge=-255, le=255) + output: str + exit_code: int diff --git a/backend/app/services/opentag3d.py b/backend/app/services/opentag3d.py index 94d3ccffd..a726b2442 100644 --- a/backend/app/services/opentag3d.py +++ b/backend/app/services/opentag3d.py @@ -13,7 +13,6 @@ NDEF structure: """ import struct -from typing import TypedDict from backend.app.models.spool import Spool @@ -22,50 +21,33 @@ PAYLOAD_SIZE = 102 TAG_VERSION = 1000 # v1.000 -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. - """ +def _build_payload(spool: Spool) -> bytes: + """Build 102-byte OpenTag3D core payload from spool fields.""" 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 = (data.get("material") or "")[:5].ljust(5) + material = (spool.material or "")[:5].ljust(5) buf[0x02:0x07] = material.encode("utf-8")[:5] # 0x07: Material Modifiers (5 bytes, UTF-8, space-padded) - modifiers = (data.get("subtype") or "")[:5].ljust(5) + modifiers = (spool.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 = (data.get("brand") or "")[:16].ljust(16) + brand = (spool.brand or "")[:16].ljust(16) buf[0x1B:0x2B] = brand.encode("utf-8")[:16] # 0x2B: Color Name (32 bytes, UTF-8, space-padded) - color_name = (data.get("color_name") or "")[:32].ljust(32) + color_name = (spool.color_name or "")[:32].ljust(32) buf[0x2B:0x4B] = color_name.encode("utf-8")[:32] # 0x4B: Color 1 RGBA (4 bytes) - rgba_hex = data.get("rgba") or "00000000" + rgba_hex = spool.rgba or "00000000" try: rgba_bytes = bytes.fromhex(rgba_hex[:8].ljust(8, "0")) except ValueError: @@ -77,12 +59,11 @@ def _build_payload_from_dict(data: dict) -> 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) — 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) + # 0x5E: Target Weight (2 bytes, big-endian) + struct.pack_into(">H", buf, 0x5E, spool.label_weight or 0) - # 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)) + # 0x60: Print Temp (1 byte) — nozzle_temp_min / 5 + buf[0x60] = (spool.nozzle_temp_min or 0) // 5 # 0x61: Bed Temp (1 byte) — not tracked # 0x62: Density (2 bytes) — not tracked @@ -92,26 +73,17 @@ def _build_payload_from_dict(data: dict) -> bytes: return bytes(buf) -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, - } - ) +def encode_opentag3d(spool: Spool) -> bytes: + """Encode spool data as OpenTag3D NDEF message (CC + TLV + record + terminator). - -def _encode_ndef(payload: bytes) -> bytes: - """Wrap a 102-byte payload in CC + TLV + NDEF record + terminator.""" + Returns raw bytes ready to write to NTAG starting at page 4. + """ + payload = _build_payload(spool) 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 @@ -122,24 +94,10 @@ def _encode_ndef(payload: bytes) -> 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 7a7ddf961..a91094768 100644 --- a/backend/app/services/spoolbuddy_ssh.py +++ b/backend/app/services/spoolbuddy_ssh.py @@ -15,7 +15,6 @@ 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 @@ -140,61 +139,45 @@ 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, str | None]: +) -> tuple[int, str, str]: """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, 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. + 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. """ - 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=known_hosts, + known_hosts=None, # equivalent to StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null 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", None + return -1, "", "SSH command timed out" except (asyncssh.Error, OSError) as exc: - return 255, "", str(exc), None + return 255, "", str(exc) 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, observed_host_key + return returncode, stdout, stderr 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. Host key verification uses TOFU: - the device's SSH public key is stored on first connect and verified on - all subsequent connections. + progress via WebSocket at each step. """ from sqlalchemy import select @@ -204,27 +187,6 @@ 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.""" @@ -252,40 +214,17 @@ 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, observed_key = await _run_ssh_command( - ip_address, "echo ok", private_key, known_hosts=known_hosts - ) + rc, _, stderr = await _run_ssh_command(ip_address, "echo ok", private_key) 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 {safe_path} && git -c safe.directory={safe_path} fetch origin {safe_branch}", + f"cd {install_path} && git -c safe.directory={install_path} fetch origin {branch}", private_key, - known_hosts=known_hosts, timeout=120, ) if rc != 0: @@ -294,12 +233,11 @@ 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 {safe_path} && git -c safe.directory={safe_path} checkout {safe_branch} " - f"&& git -c safe.directory={safe_path} reset --hard origin/{safe_branch}", + f"cd {install_path} && git -c safe.directory={install_path} checkout {branch} " + f"&& git -c safe.directory={install_path} reset --hard origin/{branch}", private_key, - known_hosts=known_hosts, ) if rc != 0: await _update_progress("error", f"git checkout/reset failed: {stderr[:200]}") @@ -307,12 +245,11 @@ 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 = shlex.quote(f"{install_path}/spoolbuddy/venv/bin/pip") - rc, _, stderr, _ = await _run_ssh_command( + venv_pip = 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: @@ -320,11 +257,10 @@ 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]}") @@ -336,19 +272,17 @@ 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: - logger.exception("SpoolBuddy %s: SSH update failed", device_id) - await _update_progress("error", "Update failed due to an internal error") + 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]}") diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py index 39d64276e..b878bb5aa 100644 --- a/backend/app/services/spoolman.py +++ b/backend/app/services/spoolman.py @@ -4,7 +4,6 @@ import asyncio import logging from dataclasses import dataclass from datetime import datetime, timezone -from typing import Literal import httpx @@ -57,25 +56,6 @@ 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.""" @@ -89,8 +69,6 @@ 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. @@ -102,9 +80,7 @@ class SpoolmanClient: """ if self._client is None: self._client = httpx.AsyncClient( - timeout=httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0), - follow_redirects=False, - verify=True, + timeout=10.0, limits=httpx.Limits( max_keepalive_connections=5, max_connections=10, @@ -193,16 +169,13 @@ class SpoolmanClient: await asyncio.sleep(retry_delay) else: logger.error("Failed to get spools from Spoolman after %d attempts: %s", max_attempts, e) - raise SpoolmanUnavailableError("Cannot reach Spoolman") from e + raise 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() @@ -211,16 +184,13 @@ class SpoolmanClient: return response.json() except Exception as e: logger.error("Failed to get filaments from Spoolman: %s", e) - raise SpoolmanUnavailableError("Cannot reach Spoolman") from e + return [] 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() @@ -229,16 +199,13 @@ class SpoolmanClient: return response.json() except Exception as e: logger.error("Failed to get external filaments from Spoolman: %s", e) - raise SpoolmanUnavailableError("Cannot reach Spoolman") from e + return [] 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() @@ -247,7 +214,7 @@ class SpoolmanClient: return response.json() except Exception as e: logger.error("Failed to get vendors from Spoolman: %s", e) - raise SpoolmanUnavailableError("Cannot reach Spoolman") from e + return [] async def create_vendor(self, name: str) -> dict | None: """Create a new vendor in Spoolman. @@ -307,7 +274,6 @@ 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, @@ -319,7 +285,6 @@ 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) @@ -350,8 +315,6 @@ 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 @@ -458,278 +421,6 @@ 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. @@ -1281,14 +972,7 @@ 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 973e38ecd..91b6aa58b 100644 --- a/backend/app/services/spoolman_tracking.py +++ b/backend/app/services/spoolman_tracking.py @@ -71,7 +71,6 @@ 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): @@ -313,16 +312,9 @@ async def _get_spoolman_client_with_fallback(): spoolman_url = await get_setting(db, "spoolman_url") if 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 + client = await init_spoolman_client(spoolman_url) - if not client: - return None - if not await client.health_check(): - logger.warning("Spoolman health check failed; skipping usage reporting") + if not client or not await client.health_check(): return None return client diff --git a/backend/tests/integration/test_auth_apikey_rbac.py b/backend/tests/integration/test_auth_apikey_rbac.py deleted file mode 100644 index bf2d287df..000000000 --- a/backend/tests/integration/test_auth_apikey_rbac.py +++ /dev/null @@ -1,160 +0,0 @@ -"""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 a5794efef..05bd051e2 100644 --- a/backend/tests/integration/test_spoolbuddy.py +++ b/backend/tests/integration/test_spoolbuddy.py @@ -7,11 +7,9 @@ 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" @@ -529,7 +527,7 @@ class TestWriteTagEndpoints: json={ "device_id": device.device_id, "spool_id": spool.id, - "tag_uid": "04AABBCC", + "tag_uid": "04AABB", "success": False, "message": "Write or verification failed", }, @@ -561,7 +559,7 @@ class TestWriteTagEndpoints: json={ "device_id": device.device_id, "spool_id": spool.id, - "tag_uid": "AABBCCDD", + "tag_uid": "AABB", "success": True, }, ) @@ -1030,46 +1028,6 @@ 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): @@ -1257,1058 +1215,3 @@ 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 deleted file mode 100644 index 353655c2b..000000000 --- a/backend/tests/integration/test_spoolman_inventory_api.py +++ /dev/null @@ -1,1524 +0,0 @@ -"""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/test_spoolbuddy_ssh.py b/backend/tests/unit/services/test_spoolbuddy_ssh.py similarity index 60% rename from backend/tests/unit/test_spoolbuddy_ssh.py rename to backend/tests/unit/services/test_spoolbuddy_ssh.py index 56597d299..1fbfd430e 100644 --- a/backend/tests/unit/test_spoolbuddy_ssh.py +++ b/backend/tests/unit/services/test_spoolbuddy_ssh.py @@ -223,30 +223,25 @@ 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, observed_key = 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 == 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)] - # TOFU default: known_hosts=None on first connect + # Host-key verification is disabled (equivalent to StrictHostKeyChecking=no) 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 @@ -255,56 +250,13 @@ async def test_run_ssh_command_success(tmp_path): assert run_args.kwargs.get("check") is False -@pytest.mark.asyncio -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 - - 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() - 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.""" + """Regression guard: _run_ssh_command must not spawn any subprocess. + + 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") @@ -315,7 +267,6 @@ 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(return_value=None) mock_conn.__aenter__ = AsyncMock(return_value=mock_conn) mock_conn.__aexit__ = AsyncMock(return_value=False) @@ -340,7 +291,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 == "" @@ -357,7 +308,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 @@ -369,6 +320,9 @@ 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(): @@ -378,7 +332,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 @@ -393,7 +347,6 @@ 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 @@ -422,9 +375,9 @@ async def test_perform_ssh_update_success(tmp_path): ssh_calls = [] - async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): + async def mock_ssh(ip, cmd, key, timeout=60): ssh_calls.append(cmd) - return 0, "ok", "", "ssh-ed25519 AAAA fakehostkey" + return 0, "ok", "" _, mock_ctx, mock_ws = _make_update_mocks(tmp_path) @@ -432,7 +385,6 @@ 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), ): @@ -453,82 +405,6 @@ 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.""" @@ -537,10 +413,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, *, known_hosts=None, timeout=60): + async def mock_ssh(ip, cmd, key, timeout=60): if "echo ok" in cmd: - return 255, "", "Connection refused", None - return 0, "", "", None + return 255, "", "Connection refused" + return 0, "", "" mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path) @@ -570,11 +446,11 @@ async def test_perform_ssh_update_git_fetch_failure(tmp_path): ssh_calls = [] - async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60): + async def mock_ssh(ip, cmd, key, timeout=60): ssh_calls.append(cmd) if "fetch" in cmd: - return 1, "", "fatal: could not read from remote", None - return 0, "ok", "", None + return 1, "", "fatal: could not read from remote" + return 0, "ok", "" _, mock_ctx, mock_ws = _make_update_mocks(tmp_path) @@ -582,7 +458,6 @@ 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), ): @@ -592,83 +467,3 @@ 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/services/test_spoolman_service.py b/backend/tests/unit/services/test_spoolman_service.py index ffa45604d..ffcc66766 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, init_spoolman_client +from backend.app.services.spoolman import AMSTray, SpoolmanClient class TestIsBambuLabSpool: @@ -444,50 +444,3 @@ 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 610176e6c..52b5a5efd 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_over_tag_uid(self): + def test_prefers_tray_uuid(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): + def test_falls_back_to_tag_uid(self): tray = {"tray_uuid": "", "tag_uid": "DEADBEEF"} assert _resolve_spool_tag(tray) == "DEADBEEF" - def test_falls_back_to_tag_uid_when_uuid_zero(self): + def test_skips_zero_uuid(self): tray = {"tray_uuid": "00000000000000000000000000000000", "tag_uid": "DEADBEEF"} assert _resolve_spool_tag(tray) == "DEADBEEF" @@ -45,10 +45,6 @@ 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 a36d3a187..d9f2a0bbb 100644 --- a/backend/tests/unit/test_opentag3d.py +++ b/backend/tests/unit/test_opentag3d.py @@ -7,9 +7,7 @@ from backend.app.services.opentag3d import ( OPENTAG3D_MIME_TYPE, PAYLOAD_SIZE, _build_payload, - _build_payload_from_dict, encode_opentag3d, - encode_opentag3d_from_mapped, ) @@ -142,180 +140,3 @@ 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 deleted file mode 100644 index 0a1af37e0..000000000 --- a/backend/tests/unit/test_spoolbuddy_schema_validation.py +++ /dev/null @@ -1,197 +0,0 @@ -"""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/test_spoolman_inventory_helpers.py b/backend/tests/unit/test_spoolman_inventory_helpers.py deleted file mode 100644 index 35ff44b17..000000000 --- a/backend/tests/unit/test_spoolman_inventory_helpers.py +++ /dev/null @@ -1,246 +0,0 @@ -"""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 deleted file mode 100644 index ceaf58a23..000000000 --- a/backend/tests/unit/test_spoolman_inventory_methods.py +++ /dev/null @@ -1,424 +0,0 @@ -"""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 deleted file mode 100644 index 610176e6c..000000000 --- a/backend/tests/unit/test_spoolman_tracking.py +++ /dev/null @@ -1,223 +0,0 @@ -"""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 deb0a39d6..9336a62c6 100644 --- a/frontend/src/__tests__/components/SpoolFormModal.test.tsx +++ b/frontend/src/__tests__/components/SpoolFormModal.test.tsx @@ -28,11 +28,6 @@ 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, - }), }, })); @@ -420,108 +415,6 @@ 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 deleted file mode 100644 index c78edfb26..000000000 --- a/frontend/src/__tests__/pages/InventoryPageDeepLink.test.tsx +++ /dev/null @@ -1,233 +0,0 @@ -/** - * 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 deleted file mode 100644 index fccecdc70..000000000 --- a/frontend/src/__tests__/pages/InventoryPageSearch.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * 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 6bcaffed7..63a36537a 100644 --- a/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx +++ b/frontend/src/__tests__/pages/SpoolBuddyWriteTagPage.test.tsx @@ -13,9 +13,7 @@ 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', () => ({ @@ -70,15 +68,13 @@ function renderPage() { return render( - - - - }> - } /> - - - - + + + }> + } /> + + + ); } @@ -138,49 +134,4 @@ 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 deleted file mode 100644 index 9e85e6aab..000000000 --- a/frontend/src/__tests__/utils/spoolFormValidation.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 960d16ad4..bdae1406e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,15 +2,6 @@ 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 @@ -109,7 +100,7 @@ async function request( } } - throw new ApiError(message, response.status); + throw new Error(message); } // Handle empty responses (204 No Content, etc.) @@ -2121,13 +2112,6 @@ 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 { @@ -4233,44 +4217,6 @@ 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'), @@ -5799,7 +5745,7 @@ export const spoolbuddyApi = { request<{ public_key: string }>('/spoolbuddy/ssh/public-key'), writeTag: (deviceId: string, spoolId: number) => - request<{ status: string; warnings?: string[] }>('/spoolbuddy/nfc/write-tag', { + request<{ status: 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 bad5276f6..7473b380c 100644 --- a/frontend/src/components/FilamentHoverCard.tsx +++ b/frontend/src/components/FilamentHoverCard.tsx @@ -1,7 +1,6 @@ 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, Package, Unlink } from 'lucide-react'; +import { Droplets, Link2, Copy, Check, Settings2, ExternalLink, Package, Unlink } from 'lucide-react'; import { isLightColor } from '../utils/colors'; interface FilamentData { @@ -52,7 +51,6 @@ 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); @@ -293,20 +291,20 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp )} - {/* Open in inventory button (when already linked to a Spoolman spool) */} - {spoolman.linkedSpoolId && ( + {/* Open in Spoolman button (when already linked) */} + {spoolman.linkedSpoolId && spoolman.spoolmanUrl && ( <> - + + {t('spoolman.openInSpoolman')} + {spoolman.onUnlinkSpool && (data.vendor !== 'Bambu Lab' || spoolman.syncMode === 'manual') && ( {inventory.onUnassignSpool && ( - {!quickAdd && !spoolmanMode && ( + {!quickAdd && (