diff --git a/CHANGELOG.md b/CHANGELOG.md index 40cccb364..7ea562f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ All notable changes to Bambuddy will be documented in this file. - **PyJWT CVE-2025-45768 (PYSEC-2025-183 / GHSA-65pc-fj4g-8rjx): permanently ignored in pip-audit** — Advisory is disputed by the PyJWT maintainers, with the advisory description literally noting *"this is disputed by the Supplier because the key length is chosen by the application that uses the library."* `fix_versions=[]` on the advisory confirms no PyJWT patch exists or will exist. Bambuddy is not affected: `backend/app/core/auth.py:184` auto-generates secrets via `secrets.token_urlsafe(64)` (~86 chars of entropy, far above any sane minimum) and the file-loaded path at `:177` rejects secrets shorter than 32 chars. Added a permanent `--ignore-vuln CVE-2025-45768` to `.github/workflows/security.yml` with an inline comment citing the file:line evidence so a future maintainer reviewing the ignore list sees why it's load-bearing. Also dropped the stale `--ignore-vuln CVE-2026-4539` for Pygments — Pygments has since shipped a patched version and the ignore is no longer load-bearing (verified: `pip-audit --ignore-vuln CVE-2025-45768` alone reports clean). ### Fixed -- **Missing-spool-assignment notification no longer false-fires on every Spoolman-mode print (#1473, reported and root-caused by @ojimpo)** — Reporter on Spoolman mode (AMS 2 Pro, all four trays bound to Spoolman spools via the Assign-Spool UI) got a `print_missing_spool_assignment` notification on every print start — 13 false positives in 7 days — each flagging trays that were correctly bound. He traced it precisely: `backend/app/services/spool_assignment_notifications.py` queried only the legacy `SpoolAssignment` table, never `SpoolmanSlotAssignment`. In Spoolman mode the legacy table is empty (bindings live in `spoolman_slot_assignments`, the source-of-truth since #1119), so `assigned_global_trays` came back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skipped `SpoolmanSlotAssignment`) and a [[feedback_inventory_modes_parity]] violation — a check present in both modes was wired only for legacy. **Fix**: the assigned-tray set is now the **union** of both tables — `SpoolAssignment` and `SpoolmanSlotAssignment` rows for the printer. Both expose `printer_id` / `ams_id` / `tray_id` in identical shape (verified against `models/spoolman_slot_assignment.py`, whose `ams_id` range 0-7 / 128-191 / 255 is fully covered by the existing `_global_tray_from_assignment()`), so the helper works on either unchanged. The union is strictly safe: it can only *add* assignments, so it never regresses legacy-mode behavior and never reports a genuinely-unassigned tray as covered. Scope note: this does not add RFID-`extra.tag` resolution (a tray bound purely via the loaded spool's RFID tag with no slot-assignment row) — that needs the Spoolman client and is a deeper change; the reported false positive is entirely covered by the union since the Assign-Spool UI writes `SpoolmanSlotAssignment`. **Tests**: 3 new in `test_spool_assignment_notifications.py` (the reporter's suggested cases) — Spoolman-only binding suppresses the notification; Spoolman partial coverage flags only the uncovered tray; mixed-mode (A1 legacy + A2 Spoolman) union covers all used trays. The test fake now routes `execute()` by target table so either mode can be exercised; the existing legacy-mode test still passes unchanged. 4 notification tests green; backend ruff clean. +- **Missing-spool-assignment notification no longer false-fires on every Spoolman-mode print (#1473, reported and root-caused by @ojimpo)** — Reporter on Spoolman mode (AMS 2 Pro, all four trays bound to Spoolman spools via the Assign-Spool UI) got a `print_missing_spool_assignment` notification on every print start — 13 false positives in 7 days — each flagging trays that were correctly bound. He traced it precisely: `backend/app/services/spool_assignment_notifications.py` queried only the legacy `SpoolAssignment` table, never `SpoolmanSlotAssignment`. In Spoolman mode the legacy table is empty (bindings live in `spoolman_slot_assignments`, the source-of-truth since #1119), so `assigned_global_trays` came back empty and every used tray was reported missing. Same class of miss as #1459 (the weight tracker also skipped `SpoolmanSlotAssignment`) and a [[feedback_inventory_modes_parity]] violation — a check present in both modes was wired only for legacy. **Fix**: the assigned-tray set is now the **union** of both tables — `SpoolAssignment` and `SpoolmanSlotAssignment` rows for the printer. Both expose `printer_id` / `ams_id` / `tray_id` in identical shape (verified against `models/spoolman_slot_assignment.py`, whose `ams_id` range 0-7 / 128-191 / 255 is fully covered by the existing `_global_tray_from_assignment()`), so the helper works on either unchanged. The union is strictly safe: it can only *add* assignments, so it never regresses legacy-mode behavior and never reports a genuinely-unassigned tray as covered. Scope note: this does not add RFID-`extra.tag` resolution (a tray bound purely via the loaded spool's RFID tag with no slot-assignment row) — that needs the Spoolman client and is a deeper change; the reported false positive is entirely covered by the union since the Assign-Spool UI writes `SpoolmanSlotAssignment`. **Tests**: 3 new in `test_spool_assignment_notifications.py` (the reporter's suggested cases) — Spoolman-only binding suppresses the notification; Spoolman partial coverage flags only the uncovered tray; mixed-mode (A1 legacy + A2 Spoolman) union covers all used trays. The test fake now routes `execute()` by target table so either mode can be exercised; the existing legacy-mode test still passes unchanged. 4 notification tests green; backend ruff clean. **Audit follow-up**: a sweep of every `SpoolAssignment` consumer confirmed the other internal-mode-only users (`usage_tracker.py`, `spool_tag_matcher.py`, `routes/inventory.py`) are correct — internal and Spoolman modes have parallel implementations by design — but surfaced an asymmetry in `routes/settings.py`: the Spoolman-mode toggle cleared `SpoolAssignment` when switching *on* but never cleared `SpoolmanSlotAssignment` when switching *off*, so stale Spoolman rows lingered. Harmless before, but now that the notification unions both tables those stale rows would wrongly count as "assigned" in internal mode and suppress a legitimate warning. Added the symmetric clear — switching back to internal mode now deletes `SpoolmanSlotAssignment` rows, mirroring the existing on-switch behavior. 1 integration test in `test_spoolman_slot_assignments.py::TestModeSwitchClearsAssignments` covers it; 23 slot-assignment + 45 settings/slot tests green. - **Local Profiles: the search bar no longer disappears when a query matches nothing (#1470, reported by @pwostran)** — Typing a query in Settings → Local Profiles that matched no preset made the search bar itself vanish, leaving the user unable to clear or edit the query without a full page refresh. Root cause in `frontend/src/components/LocalProfilesView.tsx`: the search bar was gated on `{totalCount > 0 && …}`, and `totalCount` is the sum of the *post-filter* `filaments` / `printers` / `processes` lengths — so the moment the query filtered every column to empty, `totalCount` hit 0 and the search bar unmounted along with the columns. The `totalCount === 0` "No local presets yet" empty state then took over, which also misleadingly implied nothing was imported. **Fix**: added `hasAnyPresets`, computed from the *pre-filter* preset counts (`presets?.filament/printer/process` lengths), and gated the search bar on that instead — it stays mounted as long as any preset exists, regardless of the query. The empty state is now split: `!hasAnyPresets` shows the genuine "No local presets yet" + import hint, while `hasAnyPresets && totalCount === 0` shows a new "No presets match your search" message (with a search icon) so the two cases are no longer conflated. New `noSearchResults` i18n key added with real translations in all 8 locales (en/de/fr/it/ja/pt-BR/zh-CN/zh-TW). **Tests**: 1 new in `LocalProfilesView.test.tsx` — types a non-matching query and asserts the search bar is still in the DOM, retains the typed value, and the no-matches message renders. 10 LocalProfilesView tests green; i18n parity 4859 keys × 8 locales; frontend build clean. diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index fac89f752..9bff7dc38 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -412,6 +412,16 @@ async def update_spoolman_settings( result = await db.execute(delete(SpoolAssignment)) logger.info("Cleared %d spool assignments on switch to Spoolman mode", result.rowcount) + # Switching back to internal mode: clear Spoolman slot assignments — the + # symmetric counterpart of the clear above. Without this, stale + # spoolman_slot_assignments rows linger and would wrongly count as + # "assigned" in any mode-agnostic check (e.g. the missing-spool- + # assignment notification, which unions both tables — #1473). + elif old_val.lower() == "true" and new_val.lower() != "true": + from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment + + result = await db.execute(delete(SpoolmanSlotAssignment)) + logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount) if "spoolman_url" in settings: await set_setting(db, "spoolman_url", settings["spoolman_url"]) if "spoolman_sync_mode" in settings: diff --git a/backend/tests/integration/test_spoolman_slot_assignments.py b/backend/tests/integration/test_spoolman_slot_assignments.py index ed15251ba..226054d88 100644 --- a/backend/tests/integration/test_spoolman_slot_assignments.py +++ b/backend/tests/integration/test_spoolman_slot_assignments.py @@ -577,3 +577,33 @@ class TestCascadeDeletePrinter: select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id) ) assert post.scalars().all() == [] + + +class TestModeSwitchClearsAssignments: + """#1473 follow-up — the Spoolman mode toggle clears the other mode's + slot-assignment table so stale rows can't bleed across a mode switch.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_switch_to_internal_mode_clears_spoolman_slot_assignments( + self, async_client: AsyncClient, db_session, test_printer + ): + """Switching Spoolman OFF deletes spoolman_slot_assignments rows — the + symmetric counterpart of clearing legacy spool_assignment rows when + switching ON. Stale rows would otherwise wrongly count as 'assigned' + in mode-agnostic checks (e.g. the missing-spool-assignment notification, + which unions both tables).""" + from backend.app.models.settings import Settings + from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment + + db_session.add(Settings(key="spoolman_enabled", value="true")) + db_session.add(SpoolmanSlotAssignment(printer_id=test_printer.id, ams_id=0, tray_id=0, spoolman_spool_id=1)) + await db_session.commit() + + resp = await async_client.put("/api/v1/settings/spoolman", json={"spoolman_enabled": "false"}) + assert resp.status_code == 200 + + rows = await db_session.execute( + select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == test_printer.id) + ) + assert rows.scalars().all() == []