test_spoolman_inventory_api.py 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524
  1. """Integration tests for the Spoolman inventory proxy endpoints.
  2. These tests verify that /api/v1/spoolman/inventory/spools/* correctly
  3. translates between Spoolman's data model and Bambuddy's InventorySpool format.
  4. """
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from httpx import AsyncClient
  8. # ---------------------------------------------------------------------------
  9. # Shared fixtures
  10. # ---------------------------------------------------------------------------
  11. SAMPLE_SPOOLMAN_SPOOL = {
  12. "id": 42,
  13. "filament": {
  14. "id": 7,
  15. "name": "PLA Basic",
  16. "material": "PLA",
  17. "color_hex": "FF0000",
  18. "weight": 1000,
  19. "vendor": {"id": 3, "name": "Bambu Lab"},
  20. },
  21. "remaining_weight": 750.0,
  22. "used_weight": 250.0,
  23. "location": "Printer1 - AMS A1",
  24. "comment": "test note",
  25. "first_used": "2024-01-01T00:00:00+00:00",
  26. "last_used": "2024-02-01T00:00:00+00:00",
  27. "registered": "2024-01-01T00:00:00+00:00",
  28. "archived": False,
  29. "price": None,
  30. "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"'},
  31. }
  32. @pytest.fixture
  33. async def spoolman_settings(db_session):
  34. """Create Spoolman settings in the database (enabled with URL)."""
  35. from backend.app.models.settings import Settings
  36. enabled_setting = Settings(key="spoolman_enabled", value="true")
  37. url_setting = Settings(key="spoolman_url", value="http://localhost:7912")
  38. db_session.add(enabled_setting)
  39. db_session.add(url_setting)
  40. await db_session.commit()
  41. return {"enabled": enabled_setting, "url": url_setting}
  42. @pytest.fixture
  43. def mock_spoolman_client():
  44. """Mock the Spoolman client with a sample spool."""
  45. mock_client = MagicMock()
  46. mock_client.base_url = "http://localhost:7912"
  47. mock_client.health_check = AsyncMock(return_value=True)
  48. mock_client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOLMAN_SPOOL])
  49. mock_client.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  50. mock_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  51. mock_client.delete_spool = AsyncMock(return_value=True)
  52. mock_client.set_spool_archived = AsyncMock(
  53. side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
  54. )
  55. mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  56. mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  57. mock_client.find_or_create_filament = AsyncMock(return_value=7)
  58. with (
  59. patch(
  60. "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
  61. AsyncMock(return_value=mock_client),
  62. ),
  63. patch(
  64. "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
  65. AsyncMock(return_value=mock_client),
  66. ),
  67. ):
  68. yield mock_client
  69. class TestSpoolmanInventoryMapping:
  70. """Tests for the Spoolman → InventorySpool data mapping."""
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. async def test_list_spools_returns_inventory_format(
  74. self,
  75. async_client: AsyncClient,
  76. spoolman_settings,
  77. mock_spoolman_client,
  78. ):
  79. """GET /spoolman/inventory/spools returns spools in InventorySpool format."""
  80. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  81. assert response.status_code == 200
  82. spools = response.json()
  83. assert isinstance(spools, list)
  84. assert len(spools) == 1
  85. spool = spools[0]
  86. assert spool["id"] == 42
  87. assert spool["material"] == "PLA"
  88. assert spool["subtype"] == "Basic"
  89. assert spool["brand"] == "Bambu Lab"
  90. assert spool["label_weight"] == 1000
  91. assert spool["weight_used"] == 250.0
  92. assert spool["note"] == "test note"
  93. assert spool["data_origin"] == "spoolman"
  94. assert spool["tag_type"] == "spoolman"
  95. # RRGGBB + FF alpha
  96. assert spool["rgba"] == "FF0000FF"
  97. # Spoolman location mapped to storage_location
  98. assert spool["storage_location"] == "Printer1 - AMS A1"
  99. # RFID tag: 32-char → tray_uuid
  100. assert spool["tray_uuid"] == "AABBCCDDEEFF0011AABBCCDDEEFF0011"
  101. assert spool["tag_uid"] is None
  102. @pytest.mark.asyncio
  103. @pytest.mark.integration
  104. async def test_get_single_spool(
  105. self,
  106. async_client: AsyncClient,
  107. spoolman_settings,
  108. mock_spoolman_client,
  109. ):
  110. """GET /spoolman/inventory/spools/{id} returns a single spool."""
  111. response = await async_client.get("/api/v1/spoolman/inventory/spools/42")
  112. assert response.status_code == 200
  113. spool = response.json()
  114. assert spool["id"] == 42
  115. assert spool["material"] == "PLA"
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_list_includes_archived_when_requested(
  119. self,
  120. async_client: AsyncClient,
  121. spoolman_settings,
  122. mock_spoolman_client,
  123. ):
  124. """GET /spoolman/inventory/spools?include_archived=true calls Spoolman with allow_archived."""
  125. await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true")
  126. mock_spoolman_client.get_all_spools.assert_called_once_with(allow_archived=True)
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_archived_spool_has_archived_at(
  130. self,
  131. async_client: AsyncClient,
  132. spoolman_settings,
  133. mock_spoolman_client,
  134. ):
  135. """An archived Spoolman spool maps to archived_at != None."""
  136. archived_spool = {
  137. **SAMPLE_SPOOLMAN_SPOOL,
  138. "archived": True,
  139. }
  140. mock_spoolman_client.get_all_spools.return_value = [archived_spool]
  141. response = await async_client.get("/api/v1/spoolman/inventory/spools?include_archived=true")
  142. spool = response.json()[0]
  143. assert spool["archived_at"] is not None
  144. @pytest.mark.asyncio
  145. @pytest.mark.integration
  146. async def test_malformed_spool_skipped_in_list(
  147. self,
  148. async_client: AsyncClient,
  149. spoolman_settings,
  150. mock_spoolman_client,
  151. ):
  152. """A spool with an invalid id (e.g. 0) is silently skipped; others still appear."""
  153. bad_spool = {**SAMPLE_SPOOLMAN_SPOOL, "id": 0}
  154. mock_spoolman_client.get_all_spools.return_value = [bad_spool, SAMPLE_SPOOLMAN_SPOOL]
  155. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  156. assert response.status_code == 200
  157. spools = response.json()
  158. # bad_spool is dropped; the valid one survives
  159. assert len(spools) == 1
  160. assert spools[0]["id"] == 42
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_list_spools_returns_503_when_spoolman_unavailable(
  164. self,
  165. async_client: AsyncClient,
  166. spoolman_settings,
  167. mock_spoolman_client,
  168. ):
  169. """GET /spoolman/inventory/spools returns 503 when Spoolman is unreachable (H10)."""
  170. from backend.app.services.spoolman import SpoolmanUnavailableError
  171. mock_spoolman_client.get_all_spools.side_effect = SpoolmanUnavailableError("down")
  172. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  173. assert response.status_code == 503
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_tag_uid_16char_maps_correctly(
  177. self,
  178. async_client: AsyncClient,
  179. spoolman_settings,
  180. mock_spoolman_client,
  181. ):
  182. """A 16-char tag maps to tag_uid, not tray_uuid."""
  183. spool_with_short_tag = {
  184. **SAMPLE_SPOOLMAN_SPOOL,
  185. "extra": {"tag": '"AABBCCDDEEFF0011"'},
  186. }
  187. mock_spoolman_client.get_all_spools.return_value = [spool_with_short_tag]
  188. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  189. spool = response.json()[0]
  190. assert spool["tag_uid"] == "AABBCCDDEEFF0011"
  191. assert spool["tray_uuid"] is None
  192. class TestSpoolmanInventoryCRUD:
  193. """Tests for create, update, delete, archive, restore operations."""
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_not_enabled_returns_400(self, async_client: AsyncClient):
  197. """All endpoints return 400 when Spoolman is not enabled."""
  198. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  199. assert response.status_code == 400
  200. assert "not enabled" in response.json()["detail"].lower()
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_create_spool(
  204. self,
  205. async_client: AsyncClient,
  206. spoolman_settings,
  207. mock_spoolman_client,
  208. ):
  209. """POST /spoolman/inventory/spools creates a spool via Spoolman."""
  210. payload = {
  211. "material": "PLA",
  212. "subtype": "Basic",
  213. "brand": "Bambu Lab",
  214. "rgba": "FF0000FF",
  215. "label_weight": 1000,
  216. "weight_used": 0,
  217. }
  218. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  219. assert response.status_code == 200
  220. mock_spoolman_client.find_or_create_filament.assert_called_once()
  221. mock_spoolman_client.create_spool.assert_called_once()
  222. data = response.json()
  223. assert data["material"] == "PLA"
  224. @pytest.mark.asyncio
  225. @pytest.mark.integration
  226. async def test_bulk_create_spools(
  227. self,
  228. async_client: AsyncClient,
  229. spoolman_settings,
  230. mock_spoolman_client,
  231. ):
  232. """POST /spoolman/inventory/spools/bulk creates multiple spools."""
  233. payload = {
  234. "spool": {"material": "PETG", "label_weight": 1000, "weight_used": 0},
  235. "quantity": 3,
  236. }
  237. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  238. assert response.status_code == 200
  239. assert mock_spoolman_client.create_spool.call_count == 3
  240. @pytest.mark.asyncio
  241. @pytest.mark.integration
  242. async def test_bulk_create_quantity_out_of_range_returns_422(
  243. self,
  244. async_client: AsyncClient,
  245. spoolman_settings,
  246. mock_spoolman_client,
  247. ):
  248. """Bulk create quantity outside 1-50 is rejected with 422 (not silently clamped)."""
  249. payload = {
  250. "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0},
  251. "quantity": 999,
  252. }
  253. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  254. assert response.status_code == 422
  255. @pytest.mark.asyncio
  256. @pytest.mark.integration
  257. async def test_bulk_create_quantity_zero_returns_422(
  258. self,
  259. async_client: AsyncClient,
  260. spoolman_settings,
  261. mock_spoolman_client,
  262. ):
  263. """Bulk create quantity of 0 is rejected with 422."""
  264. payload = {
  265. "spool": {"material": "ABS", "label_weight": 1000, "weight_used": 0},
  266. "quantity": 0,
  267. }
  268. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  269. assert response.status_code == 422
  270. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. async def test_update_spool(
  273. self,
  274. async_client: AsyncClient,
  275. spoolman_settings,
  276. mock_spoolman_client,
  277. ):
  278. """PATCH /spoolman/inventory/spools/{id} updates a spool."""
  279. payload = {"note": "updated note", "weight_used": 100.0}
  280. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  281. assert response.status_code == 200
  282. mock_spoolman_client.update_spool_full.assert_called_once()
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_update_spool_not_found(
  286. self,
  287. async_client: AsyncClient,
  288. spoolman_settings,
  289. mock_spoolman_client,
  290. ):
  291. """PATCH returns 404 when Spoolman spool does not exist."""
  292. from backend.app.services.spoolman import SpoolmanNotFoundError
  293. mock_spoolman_client.get_spool.side_effect = SpoolmanNotFoundError("spool not found")
  294. response = await async_client.patch("/api/v1/spoolman/inventory/spools/999", json={"note": "x"})
  295. assert response.status_code == 404
  296. @pytest.mark.asyncio
  297. @pytest.mark.integration
  298. async def test_delete_spool(
  299. self,
  300. async_client: AsyncClient,
  301. spoolman_settings,
  302. mock_spoolman_client,
  303. ):
  304. """DELETE /spoolman/inventory/spools/{id} deletes a spool."""
  305. response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
  306. assert response.status_code == 200
  307. assert response.json()["status"] == "deleted"
  308. mock_spoolman_client.delete_spool.assert_called_once_with(42)
  309. @pytest.mark.asyncio
  310. @pytest.mark.integration
  311. async def test_delete_spool_failure(
  312. self,
  313. async_client: AsyncClient,
  314. spoolman_settings,
  315. mock_spoolman_client,
  316. ):
  317. """DELETE returns 503 when Spoolman is unreachable."""
  318. from backend.app.services.spoolman import SpoolmanUnavailableError
  319. mock_spoolman_client.delete_spool.side_effect = SpoolmanUnavailableError("unreachable")
  320. response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
  321. assert response.status_code == 503
  322. @pytest.mark.asyncio
  323. @pytest.mark.integration
  324. async def test_delete_spool_not_found(
  325. self,
  326. async_client: AsyncClient,
  327. spoolman_settings,
  328. mock_spoolman_client,
  329. ):
  330. """DELETE returns 404 when Spoolman reports the spool does not exist."""
  331. from backend.app.services.spoolman import SpoolmanNotFoundError
  332. mock_spoolman_client.delete_spool.side_effect = SpoolmanNotFoundError("gone")
  333. response = await async_client.delete("/api/v1/spoolman/inventory/spools/42")
  334. assert response.status_code == 404
  335. @pytest.mark.asyncio
  336. @pytest.mark.integration
  337. async def test_archive_spool_not_found(
  338. self,
  339. async_client: AsyncClient,
  340. spoolman_settings,
  341. mock_spoolman_client,
  342. ):
  343. """POST /archive returns 404 when Spoolman reports the spool does not exist."""
  344. from backend.app.services.spoolman import SpoolmanNotFoundError
  345. mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone")
  346. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
  347. assert response.status_code == 404
  348. @pytest.mark.asyncio
  349. @pytest.mark.integration
  350. async def test_restore_spool_not_found(
  351. self,
  352. async_client: AsyncClient,
  353. spoolman_settings,
  354. mock_spoolman_client,
  355. ):
  356. """POST /restore returns 404 when Spoolman reports the spool does not exist."""
  357. from backend.app.services.spoolman import SpoolmanNotFoundError
  358. mock_spoolman_client.set_spool_archived.side_effect = SpoolmanNotFoundError("gone")
  359. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore")
  360. assert response.status_code == 404
  361. @pytest.mark.asyncio
  362. @pytest.mark.integration
  363. async def test_archive_spool(
  364. self,
  365. async_client: AsyncClient,
  366. spoolman_settings,
  367. mock_spoolman_client,
  368. ):
  369. """POST /spoolman/inventory/spools/{id}/archive archives a spool."""
  370. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/archive")
  371. assert response.status_code == 200
  372. mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=True)
  373. @pytest.mark.asyncio
  374. @pytest.mark.integration
  375. async def test_restore_spool(
  376. self,
  377. async_client: AsyncClient,
  378. spoolman_settings,
  379. mock_spoolman_client,
  380. ):
  381. """POST /spoolman/inventory/spools/{id}/restore restores an archived spool."""
  382. response = await async_client.post("/api/v1/spoolman/inventory/spools/42/restore")
  383. assert response.status_code == 200
  384. mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=False)
  385. @pytest.mark.asyncio
  386. @pytest.mark.integration
  387. async def test_sync_weight(
  388. self,
  389. async_client: AsyncClient,
  390. spoolman_settings,
  391. mock_spoolman_client,
  392. ):
  393. """PATCH /spoolman/inventory/spools/{id}/weight updates remaining weight."""
  394. payload = {"weight_grams": 850.0}
  395. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json=payload)
  396. assert response.status_code == 200
  397. result = response.json()
  398. assert result["status"] == "ok"
  399. # remaining = 850 - 250 core = 600; weight_used = 1000 - 600 = 400
  400. assert result["weight_used"] == 400.0
  401. mock_spoolman_client.update_spool_full.assert_called_once_with(spool_id=42, remaining_weight=600.0)
  402. @pytest.mark.asyncio
  403. @pytest.mark.integration
  404. async def test_update_spool_returns_404_on_not_found(
  405. self,
  406. async_client: AsyncClient,
  407. spoolman_settings,
  408. mock_spoolman_client,
  409. ):
  410. """PATCH returns 404 when update_spool_full raises SpoolmanNotFoundError (I2)."""
  411. from backend.app.services.spoolman import SpoolmanNotFoundError
  412. mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone")
  413. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"})
  414. assert response.status_code == 404
  415. @pytest.mark.asyncio
  416. @pytest.mark.integration
  417. async def test_update_spool_returns_503_on_unavailable(
  418. self,
  419. async_client: AsyncClient,
  420. spoolman_settings,
  421. mock_spoolman_client,
  422. ):
  423. """PATCH returns 503 when update_spool_full raises SpoolmanUnavailableError (I2)."""
  424. from backend.app.services.spoolman import SpoolmanUnavailableError
  425. mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down")
  426. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json={"note": "x"})
  427. assert response.status_code == 503
  428. @pytest.mark.asyncio
  429. @pytest.mark.integration
  430. async def test_sync_weight_returns_404_on_not_found(
  431. self,
  432. async_client: AsyncClient,
  433. spoolman_settings,
  434. mock_spoolman_client,
  435. ):
  436. """PATCH /weight returns 404 when update_spool_full raises SpoolmanNotFoundError (I2)."""
  437. from backend.app.services.spoolman import SpoolmanNotFoundError
  438. mock_spoolman_client.update_spool_full.side_effect = SpoolmanNotFoundError("gone")
  439. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0})
  440. assert response.status_code == 404
  441. @pytest.mark.asyncio
  442. @pytest.mark.integration
  443. async def test_sync_weight_returns_503_on_unavailable(
  444. self,
  445. async_client: AsyncClient,
  446. spoolman_settings,
  447. mock_spoolman_client,
  448. ):
  449. """PATCH /weight returns 503 when update_spool_full raises SpoolmanUnavailableError (I2)."""
  450. from backend.app.services.spoolman import SpoolmanUnavailableError
  451. mock_spoolman_client.update_spool_full.side_effect = SpoolmanUnavailableError("down")
  452. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42/weight", json={"weight_grams": 500.0})
  453. assert response.status_code == 503
  454. class TestSpoolmanInventoryCostPerKg:
  455. """Tests for the two-step cost_per_kg create path (PT-C2)."""
  456. @pytest.mark.asyncio
  457. @pytest.mark.integration
  458. async def test_create_spool_with_cost_per_kg_calls_price_update(
  459. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  460. ):
  461. """POST with cost_per_kg calls update_spool_full with price= after creation."""
  462. from unittest.mock import AsyncMock
  463. mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  464. payload = {
  465. "material": "PLA",
  466. "brand": "Bambu Lab",
  467. "label_weight": 1000,
  468. "cost_per_kg": 24.99,
  469. }
  470. resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  471. assert resp.status_code == 200
  472. # update_spool_full must have been called with price=24.99
  473. calls = [
  474. c
  475. for c in mock_spoolman_client.update_spool_full.call_args_list
  476. if c.kwargs.get("price") == 24.99 or (c.args and 24.99 in c.args)
  477. ]
  478. assert len(calls) >= 1
  479. @pytest.mark.asyncio
  480. @pytest.mark.integration
  481. async def test_create_spool_without_cost_per_kg_skips_price_update(
  482. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  483. ):
  484. """POST without cost_per_kg does not call update_spool_full."""
  485. from unittest.mock import AsyncMock
  486. mock_spoolman_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  487. payload = {"material": "PLA", "brand": "Bambu Lab", "label_weight": 1000}
  488. resp = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  489. assert resp.status_code == 200
  490. mock_spoolman_client.update_spool_full.assert_not_called()
  491. class TestSpoolmanInventoryInputValidation:
  492. """Tests for input validation added as security hardening."""
  493. @pytest.mark.asyncio
  494. @pytest.mark.integration
  495. async def test_create_rejects_material_too_long(
  496. self,
  497. async_client: AsyncClient,
  498. spoolman_settings,
  499. mock_spoolman_client,
  500. ):
  501. """material longer than 64 chars is rejected with 422."""
  502. payload = {"material": "A" * 65, "label_weight": 1000, "weight_used": 0}
  503. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  504. assert response.status_code == 422
  505. @pytest.mark.asyncio
  506. @pytest.mark.integration
  507. async def test_create_rejects_note_too_long(
  508. self,
  509. async_client: AsyncClient,
  510. spoolman_settings,
  511. mock_spoolman_client,
  512. ):
  513. """note longer than 1000 chars is rejected with 422."""
  514. payload = {
  515. "material": "PLA",
  516. "label_weight": 1000,
  517. "weight_used": 0,
  518. "note": "x" * 1001,
  519. }
  520. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  521. assert response.status_code == 422
  522. @pytest.mark.asyncio
  523. @pytest.mark.integration
  524. async def test_create_rejects_negative_weight_used(
  525. self,
  526. async_client: AsyncClient,
  527. spoolman_settings,
  528. mock_spoolman_client,
  529. ):
  530. """Negative weight_used is rejected with 422."""
  531. payload = {"material": "PLA", "label_weight": 1000, "weight_used": -1.0}
  532. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  533. assert response.status_code == 422
  534. @pytest.mark.asyncio
  535. @pytest.mark.integration
  536. async def test_create_rejects_zero_label_weight(
  537. self,
  538. async_client: AsyncClient,
  539. spoolman_settings,
  540. mock_spoolman_client,
  541. ):
  542. """label_weight of 0 is rejected (minimum is 1)."""
  543. payload = {"material": "PLA", "label_weight": 0, "weight_used": 0}
  544. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  545. assert response.status_code == 422
  546. @pytest.mark.asyncio
  547. @pytest.mark.integration
  548. async def test_create_rejects_invalid_rgba(
  549. self,
  550. async_client: AsyncClient,
  551. spoolman_settings,
  552. mock_spoolman_client,
  553. ):
  554. """Non-hex rgba string is rejected with 422."""
  555. payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "GGGGGGFF"}
  556. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  557. assert response.status_code == 422
  558. @pytest.mark.asyncio
  559. @pytest.mark.integration
  560. async def test_create_accepts_valid_6char_rgba(
  561. self,
  562. async_client: AsyncClient,
  563. spoolman_settings,
  564. mock_spoolman_client,
  565. ):
  566. """A valid 6-char hex rgba is accepted."""
  567. payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "FF0000"}
  568. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  569. assert response.status_code == 200
  570. @pytest.mark.asyncio
  571. @pytest.mark.integration
  572. async def test_weight_update_rejects_negative_grams(
  573. self,
  574. async_client: AsyncClient,
  575. spoolman_settings,
  576. mock_spoolman_client,
  577. ):
  578. """Negative weight_grams on weight sync endpoint is rejected with 422."""
  579. response = await async_client.patch(
  580. "/api/v1/spoolman/inventory/spools/42/weight",
  581. json={"weight_grams": -50.0},
  582. )
  583. assert response.status_code == 422
  584. @pytest.mark.asyncio
  585. @pytest.mark.integration
  586. async def test_update_rejects_tag_uid_too_long(
  587. self,
  588. async_client: AsyncClient,
  589. spoolman_settings,
  590. mock_spoolman_client,
  591. ):
  592. """tag_uid longer than 30 chars is rejected with 422 (NFC UID max 10 bytes = 20 hex chars, capped at 30)."""
  593. payload = {"tag_uid": "A" * 65}
  594. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  595. assert response.status_code == 422
  596. @pytest.mark.asyncio
  597. @pytest.mark.integration
  598. async def test_update_rejects_tray_uuid_too_long(
  599. self,
  600. async_client: AsyncClient,
  601. spoolman_settings,
  602. mock_spoolman_client,
  603. ):
  604. """tray_uuid longer than 32 chars is rejected with 422."""
  605. payload = {"tray_uuid": "B" * 65}
  606. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  607. assert response.status_code == 422
  608. @pytest.mark.asyncio
  609. @pytest.mark.integration
  610. @pytest.mark.parametrize("uuid_len", [16, 31])
  611. async def test_update_rejects_tray_uuid_too_short(
  612. self,
  613. async_client: AsyncClient,
  614. spoolman_settings,
  615. mock_spoolman_client,
  616. uuid_len: int,
  617. ):
  618. """tray_uuid shorter than 32 chars is rejected (min_length=max_length=32)."""
  619. payload = {"tray_uuid": "A" * uuid_len}
  620. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  621. assert response.status_code == 422
  622. @pytest.mark.asyncio
  623. @pytest.mark.integration
  624. async def test_update_rejects_rgba_nine_chars(
  625. self,
  626. async_client: AsyncClient,
  627. spoolman_settings,
  628. mock_spoolman_client,
  629. ):
  630. """rgba must be max 8 hex chars; 9-char value is rejected with 422."""
  631. payload = {"rgba": "FF0000FFA"} # 9 chars
  632. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  633. assert response.status_code == 422
  634. @pytest.mark.asyncio
  635. @pytest.mark.integration
  636. async def test_tag_uid_below_min_length_rejected(
  637. self,
  638. async_client: AsyncClient,
  639. spoolman_settings,
  640. mock_spoolman_client,
  641. ):
  642. """tag_uid shorter than 8 hex chars is rejected with 422 (PT-I5)."""
  643. payload = {"tag_uid": "AABBCC"} # 6 chars, below min_length=8
  644. resp = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  645. assert resp.status_code == 422
  646. @pytest.mark.asyncio
  647. @pytest.mark.integration
  648. async def test_invalid_spoolman_url_scheme_returns_400(
  649. self,
  650. async_client: AsyncClient,
  651. db_session,
  652. mock_spoolman_client,
  653. ):
  654. """A spoolman_url with a non-http(s) scheme is rejected."""
  655. from backend.app.models.settings import Settings
  656. db_session.add(Settings(key="spoolman_enabled", value="true"))
  657. db_session.add(Settings(key="spoolman_url", value="ftp://evil.internal/"))
  658. await db_session.commit()
  659. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  660. assert response.status_code == 400
  661. assert "http" in response.json()["detail"].lower()
  662. @pytest.mark.asyncio
  663. @pytest.mark.integration
  664. @pytest.mark.parametrize(
  665. "evil_url",
  666. [
  667. "file:///etc/passwd",
  668. "gopher://127.0.0.1:70/",
  669. "dict://internal.corp/",
  670. "http://169.254.169.254/latest/meta-data/", # AWS IMDS (link-local)
  671. "http://[::1]:7912/", # IPv6 loopback
  672. "http://0.0.0.0/", # unspecified
  673. "javascript:alert(1)",
  674. "http://224.0.0.1/", # IPv4 multicast
  675. "http://[ff02::1]/", # IPv6 multicast
  676. "http://127.1.2.3/", # 127.x.x.x loopback range
  677. "http://[::ffff:169.254.169.254]/", # IPv4-mapped IPv6 IMDS bypass
  678. ],
  679. )
  680. async def test_ssrf_blocked_schemes_and_addresses(
  681. self,
  682. async_client: AsyncClient,
  683. db_session,
  684. mock_spoolman_client,
  685. evil_url: str,
  686. ):
  687. """SSRF: any Spoolman URL that is not http(s) must be rejected with 400."""
  688. from backend.app.models.settings import Settings
  689. db_session.add(Settings(key="spoolman_enabled", value="true"))
  690. db_session.add(Settings(key="spoolman_url", value=evil_url))
  691. await db_session.commit()
  692. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  693. assert response.status_code == 400, (
  694. f"Expected 400 for SSRF URL {evil_url!r} but got {response.status_code}: {response.json()}"
  695. )
  696. @pytest.mark.asyncio
  697. @pytest.mark.integration
  698. async def test_create_rejects_storage_location_too_long(
  699. self,
  700. async_client: AsyncClient,
  701. spoolman_settings,
  702. mock_spoolman_client,
  703. ):
  704. """storage_location longer than 255 chars is rejected with 422."""
  705. payload = {
  706. "material": "PLA",
  707. "label_weight": 1000,
  708. "weight_used": 0,
  709. "storage_location": "x" * 256,
  710. }
  711. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  712. assert response.status_code == 422
  713. @pytest.mark.asyncio
  714. @pytest.mark.integration
  715. async def test_update_rejects_storage_location_too_long(
  716. self,
  717. async_client: AsyncClient,
  718. spoolman_settings,
  719. mock_spoolman_client,
  720. ):
  721. """storage_location longer than 255 chars on PATCH is rejected with 422."""
  722. payload = {"storage_location": "y" * 256}
  723. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  724. assert response.status_code == 422
  725. class TestStorageLocationPassthrough:
  726. """Tests that storage_location is correctly passed to and from Spoolman."""
  727. @pytest.mark.asyncio
  728. @pytest.mark.integration
  729. async def test_list_spools_maps_spoolman_location_to_storage_location(
  730. self,
  731. async_client: AsyncClient,
  732. spoolman_settings,
  733. mock_spoolman_client,
  734. ):
  735. """Spoolman's location field is exposed as storage_location in the response."""
  736. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  737. spool = response.json()[0]
  738. assert spool["storage_location"] == "Printer1 - AMS A1"
  739. @pytest.mark.asyncio
  740. @pytest.mark.integration
  741. async def test_list_spools_null_location_gives_null_storage_location(
  742. self,
  743. async_client: AsyncClient,
  744. spoolman_settings,
  745. mock_spoolman_client,
  746. ):
  747. """A Spoolman spool with no location gives null storage_location."""
  748. spool_no_loc = {**SAMPLE_SPOOLMAN_SPOOL, "location": None}
  749. mock_spoolman_client.get_all_spools.return_value = [spool_no_loc]
  750. response = await async_client.get("/api/v1/spoolman/inventory/spools")
  751. spool = response.json()[0]
  752. assert spool["storage_location"] is None
  753. @pytest.mark.asyncio
  754. @pytest.mark.integration
  755. async def test_create_passes_storage_location_to_spoolman(
  756. self,
  757. async_client: AsyncClient,
  758. spoolman_settings,
  759. mock_spoolman_client,
  760. ):
  761. """storage_location is forwarded as location when creating a Spoolman spool."""
  762. payload = {
  763. "material": "PLA",
  764. "label_weight": 1000,
  765. "weight_used": 0,
  766. "storage_location": "Shelf B",
  767. }
  768. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  769. assert response.status_code == 200
  770. mock_spoolman_client.create_spool.assert_called_once()
  771. _, kwargs = mock_spoolman_client.create_spool.call_args
  772. assert kwargs.get("location") == "Shelf B"
  773. @pytest.mark.asyncio
  774. @pytest.mark.integration
  775. async def test_update_passes_storage_location_to_spoolman(
  776. self,
  777. async_client: AsyncClient,
  778. spoolman_settings,
  779. mock_spoolman_client,
  780. ):
  781. """storage_location is forwarded as location when updating a Spoolman spool."""
  782. payload = {"storage_location": "Drawer 3"}
  783. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  784. assert response.status_code == 200
  785. mock_spoolman_client.update_spool_full.assert_called_once()
  786. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  787. assert kwargs.get("location") == "Drawer 3"
  788. assert kwargs.get("clear_location") is False
  789. @pytest.mark.asyncio
  790. @pytest.mark.integration
  791. async def test_update_clears_storage_location_when_null_sent(
  792. self,
  793. async_client: AsyncClient,
  794. spoolman_settings,
  795. mock_spoolman_client,
  796. ):
  797. """Explicitly sending null storage_location clears the Spoolman location."""
  798. payload = {"storage_location": None}
  799. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  800. assert response.status_code == 200
  801. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  802. assert kwargs.get("clear_location") is True
  803. @pytest.mark.asyncio
  804. @pytest.mark.integration
  805. async def test_update_clears_storage_location_when_empty_string_sent(
  806. self,
  807. async_client: AsyncClient,
  808. spoolman_settings,
  809. mock_spoolman_client,
  810. ):
  811. """Sending an empty string for storage_location also clears the Spoolman location."""
  812. payload = {"storage_location": ""}
  813. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  814. assert response.status_code == 200
  815. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  816. assert kwargs.get("clear_location") is True
  817. class TestColorNamePassthrough:
  818. """color_name is forwarded to find_or_create_filament on create and update (B6 / T5)."""
  819. @pytest.mark.asyncio
  820. @pytest.mark.integration
  821. async def test_create_passes_color_name_to_filament(
  822. self,
  823. async_client: AsyncClient,
  824. spoolman_settings,
  825. mock_spoolman_client,
  826. ):
  827. """color_name from the create payload is forwarded to find_or_create_filament."""
  828. payload = {
  829. "material": "PLA",
  830. "label_weight": 1000,
  831. "weight_used": 0,
  832. "color_name": "Bambu Green",
  833. }
  834. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  835. assert response.status_code == 200
  836. mock_spoolman_client.find_or_create_filament.assert_called_once()
  837. _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
  838. assert kwargs.get("color_name") == "Bambu Green"
  839. @pytest.mark.asyncio
  840. @pytest.mark.integration
  841. async def test_update_passes_color_name_to_filament(
  842. self,
  843. async_client: AsyncClient,
  844. spoolman_settings,
  845. mock_spoolman_client,
  846. ):
  847. """color_name from the update payload is forwarded to find_or_create_filament."""
  848. payload = {"color_name": "Jade White"}
  849. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  850. assert response.status_code == 200
  851. mock_spoolman_client.find_or_create_filament.assert_called_once()
  852. _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
  853. assert kwargs.get("color_name") == "Jade White"
  854. @pytest.mark.asyncio
  855. @pytest.mark.integration
  856. async def test_update_omits_color_name_when_not_provided(
  857. self,
  858. async_client: AsyncClient,
  859. spoolman_settings,
  860. mock_spoolman_client,
  861. ):
  862. """When color_name is not in the PATCH payload, the existing filament color_name is used."""
  863. payload = {"note": "no color_name here"}
  864. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  865. assert response.status_code == 200
  866. _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
  867. # color_name falls back to current filament's color_name (which is None in test fixture)
  868. assert kwargs.get("color_name") is None
  869. class TestSpoolmanInventoryAuth:
  870. """Write/delete endpoints require INVENTORY_UPDATE when auth is enabled."""
  871. @pytest.fixture
  872. async def auth_and_spoolman_settings(self, db_session):
  873. """Enable both Spoolman and auth."""
  874. from backend.app.models.settings import Settings
  875. db_session.add(Settings(key="spoolman_enabled", value="true"))
  876. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  877. db_session.add(Settings(key="auth_enabled", value="true"))
  878. await db_session.commit()
  879. @pytest.mark.asyncio
  880. @pytest.mark.integration
  881. @pytest.mark.parametrize(
  882. "method,path,payload",
  883. [
  884. ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}),
  885. (
  886. "POST",
  887. "/api/v1/spoolman/inventory/spools/bulk",
  888. {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1},
  889. ),
  890. ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}),
  891. ("DELETE", "/api/v1/spoolman/inventory/spools/42", None),
  892. ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None),
  893. ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None),
  894. ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}),
  895. ],
  896. )
  897. async def test_write_endpoints_require_auth(
  898. self,
  899. async_client: AsyncClient,
  900. auth_and_spoolman_settings,
  901. method: str,
  902. path: str,
  903. payload: dict | None,
  904. ):
  905. """All write/delete endpoints return 401 when auth is enabled and no token is provided."""
  906. response = await async_client.request(method, path, json=payload)
  907. assert response.status_code == 401, (
  908. f"{method} {path} should require auth but got {response.status_code}: {response.json()}"
  909. )
  910. @pytest.mark.asyncio
  911. @pytest.mark.integration
  912. @pytest.mark.parametrize(
  913. "method,path",
  914. [
  915. ("GET", "/api/v1/spoolman/inventory/spools"),
  916. ("GET", "/api/v1/spoolman/inventory/spools/42"),
  917. ],
  918. )
  919. async def test_read_endpoints_require_auth(
  920. self,
  921. async_client: AsyncClient,
  922. auth_and_spoolman_settings,
  923. method: str,
  924. path: str,
  925. ):
  926. """Read endpoints also require auth when auth is enabled."""
  927. response = await async_client.request(method, path)
  928. assert response.status_code == 401, (
  929. f"{method} {path} should require auth but got {response.status_code}: {response.json()}"
  930. )
  931. @pytest.fixture
  932. async def viewer_token(self, db_session):
  933. """Create a Viewer-group user (INVENTORY_READ only, no INVENTORY_UPDATE)."""
  934. from sqlalchemy import select
  935. from backend.app.core.auth import create_access_token, get_password_hash
  936. from backend.app.models.group import Group
  937. from backend.app.models.settings import Settings
  938. from backend.app.models.user import User
  939. db_session.add(Settings(key="spoolman_enabled", value="true"))
  940. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  941. db_session.add(Settings(key="auth_enabled", value="true"))
  942. await db_session.commit()
  943. viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  944. viewer = User(
  945. username="sm_inv_viewer",
  946. password_hash=get_password_hash("pw"),
  947. is_active=True,
  948. )
  949. viewer.groups.append(viewer_group)
  950. db_session.add(viewer)
  951. await db_session.commit()
  952. return create_access_token(data={"sub": viewer.username})
  953. @pytest.mark.asyncio
  954. @pytest.mark.integration
  955. @pytest.mark.parametrize(
  956. "method,path,payload",
  957. [
  958. ("POST", "/api/v1/spoolman/inventory/spools", {"material": "PLA", "label_weight": 1000, "weight_used": 0}),
  959. (
  960. "POST",
  961. "/api/v1/spoolman/inventory/spools/bulk",
  962. {"spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0}, "quantity": 1},
  963. ),
  964. ("PATCH", "/api/v1/spoolman/inventory/spools/42", {"note": "x"}),
  965. ("DELETE", "/api/v1/spoolman/inventory/spools/42", None),
  966. ("POST", "/api/v1/spoolman/inventory/spools/42/archive", None),
  967. ("POST", "/api/v1/spoolman/inventory/spools/42/restore", None),
  968. ("PATCH", "/api/v1/spoolman/inventory/spools/42/weight", {"weight_grams": 100.0}),
  969. ],
  970. )
  971. async def test_write_endpoints_return_403_for_viewer(
  972. self,
  973. async_client: AsyncClient,
  974. viewer_token,
  975. method: str,
  976. path: str,
  977. payload: dict | None,
  978. ):
  979. """Viewer-group users (INVENTORY_READ, no INVENTORY_UPDATE) get 403 on write endpoints."""
  980. response = await async_client.request(
  981. method,
  982. path,
  983. json=payload,
  984. headers={"Authorization": f"Bearer {viewer_token}"},
  985. )
  986. assert response.status_code == 403, (
  987. f"{method} {path} should return 403 for read-only user but got {response.status_code}: {response.json()}"
  988. )
  989. # Error body must mention the permission string so a "banned-user middleware"
  990. # regression (generic 403 with no permission context) doesn't pass silently.
  991. detail = response.json().get("detail", "")
  992. assert "inventory:update" in detail, f"Expected 'inventory:update' in 403 detail but got: {detail!r}"
  993. # ---------------------------------------------------------------------------
  994. # Additional regression tests for second-round review items
  995. # ---------------------------------------------------------------------------
  996. class TestSpoolmanInventorySecurityExtras:
  997. """Additional security/validation tests added in second review round."""
  998. @pytest.mark.asyncio
  999. @pytest.mark.integration
  1000. async def test_create_rejects_double_hash_rgba(
  1001. self,
  1002. async_client: AsyncClient,
  1003. spoolman_settings,
  1004. mock_spoolman_client,
  1005. ):
  1006. """SEC-3: rgba like '##FF0000' (double hash) must be rejected with 422."""
  1007. payload = {"material": "PLA", "label_weight": 1000, "weight_used": 0, "rgba": "##FF0000"}
  1008. response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
  1009. assert response.status_code == 422
  1010. @pytest.mark.asyncio
  1011. @pytest.mark.integration
  1012. @pytest.mark.parametrize("spool_id", [0, -1])
  1013. async def test_path_param_non_positive_spool_id_returns_422(
  1014. self,
  1015. async_client: AsyncClient,
  1016. spoolman_settings,
  1017. mock_spoolman_client,
  1018. spool_id: int,
  1019. ):
  1020. """SEC-5: /spools/0 and /spools/-1 must be rejected with 422 (Path gt=0)."""
  1021. response = await async_client.get(f"/api/v1/spoolman/inventory/spools/{spool_id}")
  1022. assert response.status_code == 422, f"Expected 422 for spool_id={spool_id} but got {response.status_code}"
  1023. @pytest.mark.asyncio
  1024. @pytest.mark.integration
  1025. @pytest.mark.parametrize(
  1026. "tag_uid,expected_status",
  1027. [
  1028. ("A" * 30, 200), # exactly at NFC UID cap — valid
  1029. ("DEADBEEF12345678", 200), # 16-char backward compat — valid
  1030. ("A" * 31, 422), # one over limit — rejected by Pydantic max_length=30
  1031. ("A" * 32, 422), # tray_uuid-length value rejected in tag_uid field
  1032. ],
  1033. )
  1034. async def test_tag_uid_length_boundary(
  1035. self,
  1036. async_client: AsyncClient,
  1037. spoolman_settings,
  1038. mock_spoolman_client,
  1039. tag_uid: str,
  1040. expected_status: int,
  1041. ):
  1042. """tag_uid boundary — 30 chars valid (NFC UID max), 31+ rejected."""
  1043. payload = {"tag_uid": tag_uid}
  1044. response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
  1045. assert response.status_code == expected_status, (
  1046. f"tag_uid len={len(tag_uid)}: expected {expected_status} but got {response.status_code}"
  1047. )
  1048. @pytest.mark.asyncio
  1049. @pytest.mark.integration
  1050. async def test_bulk_create_partial_failure_returns_207(
  1051. self,
  1052. async_client: AsyncClient,
  1053. spoolman_settings,
  1054. mock_spoolman_client,
  1055. ):
  1056. """I9: bulk create with quantity=3 where middle call fails → 207 Multi-Status."""
  1057. results = [SAMPLE_SPOOLMAN_SPOOL, None, SAMPLE_SPOOLMAN_SPOOL]
  1058. mock_spoolman_client.create_spool.side_effect = results
  1059. payload = {
  1060. "spool": {"material": "PLA", "label_weight": 1000, "weight_used": 0},
  1061. "quantity": 3,
  1062. }
  1063. response = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  1064. assert response.status_code == 207, (
  1065. f"Expected 207 Multi-Status for partial failure but got {response.status_code}"
  1066. )
  1067. body = response.json()
  1068. assert isinstance(body, dict)
  1069. assert body["requested_count"] == 3
  1070. assert body["failed_count"] == 1
  1071. assert len(body["created"]) == 2
  1072. class TestTagClearPreservesExtraKeys:
  1073. """Regression test: clearing tag_uid must not wipe unrelated Spoolman extra fields."""
  1074. @pytest.mark.asyncio
  1075. @pytest.mark.integration
  1076. async def test_tag_clear_preserves_custom_extra_key(
  1077. self,
  1078. async_client: AsyncClient,
  1079. spoolman_settings,
  1080. mock_spoolman_client,
  1081. ):
  1082. """PATCH tag_uid='' must preserve unrelated keys in Spoolman extra dict."""
  1083. spool_with_extra = {
  1084. **SAMPLE_SPOOLMAN_SPOOL,
  1085. "extra": {"tag": '"AABBCCDDEEFF0011AABBCCDDEEFF0011"', "custom_key": "keep_me"},
  1086. }
  1087. mock_spoolman_client.get_spool = AsyncMock(return_value=spool_with_extra)
  1088. mock_spoolman_client.update_spool_full = AsyncMock(return_value=spool_with_extra)
  1089. response = await async_client.patch(
  1090. "/api/v1/spoolman/inventory/spools/42",
  1091. json={"tag_uid": None},
  1092. )
  1093. assert response.status_code == 200
  1094. mock_spoolman_client.update_spool_full.assert_called_once()
  1095. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1096. sent_extra = kwargs.get("extra")
  1097. assert sent_extra is not None, "extra must be sent when tag is cleared"
  1098. assert "tag" not in sent_extra, "tag key must be removed when tag_uid is cleared"
  1099. assert sent_extra.get("custom_key") == "keep_me", "unrelated extra keys must survive"
  1100. @pytest.mark.asyncio
  1101. @pytest.mark.integration
  1102. async def test_tag_clear_refetches_spool_inside_lock(
  1103. self,
  1104. async_client: AsyncClient,
  1105. spoolman_settings,
  1106. mock_spoolman_client,
  1107. ):
  1108. """B7: tag-clear does a fresh get_spool() re-fetch inside the lock, not the stale one.
  1109. Simulates a write that changes extra between the initial get_spool (used for
  1110. other field resolution) and the lock acquisition. The extra sent to
  1111. update_spool_full must come from the second (in-lock) fetch, not the first.
  1112. """
  1113. stale_extra = {"tag": '"AABBCCDD"', "custom_key": "stale_value"}
  1114. fresh_extra = {"tag": '"AABBCCDD"', "custom_key": "fresh_value"}
  1115. stale_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": stale_extra}
  1116. fresh_spool = {**SAMPLE_SPOOLMAN_SPOOL, "extra": fresh_extra}
  1117. # First call returns stale; second call (inside lock) returns fresh
  1118. mock_spoolman_client.get_spool = AsyncMock(side_effect=[stale_spool, fresh_spool])
  1119. mock_spoolman_client.update_spool_full = AsyncMock(return_value=fresh_spool)
  1120. response = await async_client.patch(
  1121. "/api/v1/spoolman/inventory/spools/42",
  1122. json={"tag_uid": None, "tray_uuid": None},
  1123. )
  1124. assert response.status_code == 200
  1125. # get_spool called twice: once for field resolution, once for fresh extra fetch
  1126. assert mock_spoolman_client.get_spool.call_count == 2
  1127. _, kwargs = mock_spoolman_client.update_spool_full.call_args
  1128. sent_extra = kwargs.get("extra")
  1129. assert sent_extra is not None
  1130. assert "tag" not in sent_extra
  1131. # custom_key must come from the fresh re-fetch, not the stale first fetch
  1132. assert sent_extra.get("custom_key") == "fresh_value"
  1133. class TestSpoolmanInventorySSRFSpoolBuddyPath:
  1134. """SSRF tests for _get_spoolman_client_or_none (nfc/* and scale/ endpoints)."""
  1135. @pytest.mark.asyncio
  1136. @pytest.mark.integration
  1137. @pytest.mark.parametrize(
  1138. "evil_url",
  1139. [
  1140. "file:///etc/passwd",
  1141. "http://169.254.169.254/latest/meta-data/", # AWS IMDS
  1142. "http://[::1]:7912/", # IPv6 loopback
  1143. "http://0.0.0.0/", # unspecified
  1144. "http://10.0.0.1/", # RFC-1918 private
  1145. "http://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS bypass
  1146. ],
  1147. )
  1148. async def test_nfc_tag_scanned_with_ssrf_url_ignores_spoolman(
  1149. self,
  1150. async_client: AsyncClient,
  1151. db_session,
  1152. evil_url: str,
  1153. ):
  1154. """SSRF: _get_spoolman_client_or_none silently disables Spoolman for unsafe URLs
  1155. on the SpoolBuddy NFC path (tag-scanned broadcasts unknown_tag, not 400)."""
  1156. from backend.app.models.settings import Settings
  1157. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1158. db_session.add(Settings(key="spoolman_url", value=evil_url))
  1159. await db_session.commit()
  1160. from unittest.mock import AsyncMock, patch
  1161. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1162. mock_ws.broadcast = AsyncMock()
  1163. resp = await async_client.post(
  1164. "/api/v1/spoolbuddy/nfc/tag-scanned",
  1165. json={"device_id": "sb-ssrf", "tag_uid": "AABBCCDD"},
  1166. )
  1167. # Must not crash or proxy the SSRF URL — unknown_tag is the safe degraded response
  1168. assert resp.status_code == 200
  1169. if mock_ws.broadcast.called:
  1170. msg = mock_ws.broadcast.call_args[0][0]
  1171. assert msg["type"] == "spoolbuddy_unknown_tag"
  1172. @pytest.mark.asyncio
  1173. @pytest.mark.integration
  1174. @pytest.mark.parametrize(
  1175. "evil_url",
  1176. [
  1177. "http://169.254.169.254/latest/meta-data/", # AWS IMDS
  1178. "http://10.0.0.1/", # RFC-1918 private
  1179. "http://[::ffff:169.254.169.254]/", # IPv4-mapped IMDS bypass
  1180. ],
  1181. )
  1182. async def test_nfc_write_result_with_ssrf_url_degrades_gracefully(
  1183. self,
  1184. async_client: AsyncClient,
  1185. db_session,
  1186. evil_url: str,
  1187. ):
  1188. """SSRF: write-result with unsafe Spoolman URL must not proxy to the evil host.
  1189. write-result calls Spoolman to write-back the tag UID when data_origin='spoolman'.
  1190. With an SSRF URL, _get_spoolman_client_or_none returns None so the call is skipped
  1191. and the route returns 502 (tag written but link not persisted — not a server crash).
  1192. """
  1193. import json as _json
  1194. from backend.app.models.settings import Settings
  1195. from backend.app.models.spoolbuddy_device import SpoolBuddyDevice
  1196. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1197. db_session.add(Settings(key="spoolman_url", value=evil_url))
  1198. # Register the device so the route doesn't 404 before reaching the SSRF guard.
  1199. db_session.add(
  1200. SpoolBuddyDevice(
  1201. device_id="sb-ssrf-wr",
  1202. hostname="sb-ssrf-wr.local",
  1203. ip_address="127.0.0.1",
  1204. pending_command="write_tag",
  1205. pending_write_payload=_json.dumps({"spool_id": 99, "ndef_data_hex": "DEAD", "data_origin": "spoolman"}),
  1206. )
  1207. )
  1208. await db_session.commit()
  1209. from unittest.mock import AsyncMock, patch
  1210. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1211. mock_ws.broadcast = AsyncMock()
  1212. resp = await async_client.post(
  1213. "/api/v1/spoolbuddy/nfc/write-result",
  1214. json={
  1215. "device_id": "sb-ssrf-wr",
  1216. "spool_id": 99,
  1217. "tag_uid": "AABBCCDD",
  1218. "success": True,
  1219. },
  1220. )
  1221. # 502 = tag written to NFC but Spoolman link not persisted (SSRF guard blocked it).
  1222. # Must not be 500 (crash) and must not have proxied to the evil host.
  1223. assert resp.status_code == 502
  1224. @pytest.mark.asyncio
  1225. @pytest.mark.integration
  1226. @pytest.mark.parametrize(
  1227. "evil_url",
  1228. [
  1229. "http://169.254.169.254/latest/meta-data/", # AWS IMDS
  1230. "http://10.0.0.1/", # RFC-1918 private
  1231. ],
  1232. )
  1233. async def test_scale_update_weight_with_ssrf_url_degrades_gracefully(
  1234. self,
  1235. async_client: AsyncClient,
  1236. db_session,
  1237. evil_url: str,
  1238. ):
  1239. """SSRF: scale weight update with unsafe Spoolman URL must not proxy to the evil host."""
  1240. from backend.app.models.settings import Settings
  1241. db_session.add(Settings(key="spoolman_enabled", value="true"))
  1242. db_session.add(Settings(key="spoolman_url", value=evil_url))
  1243. await db_session.commit()
  1244. from unittest.mock import AsyncMock, patch
  1245. with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
  1246. mock_ws.broadcast = AsyncMock()
  1247. resp = await async_client.post(
  1248. "/api/v1/spoolbuddy/scale/update-spool-weight",
  1249. json={"device_id": "sb-ssrf-scale", "spool_id": 1, "weight_grams": 500.0},
  1250. )
  1251. # Must not crash or proxy to an SSRF host
  1252. assert resp.status_code in (200, 404, 422)
  1253. class TestMergeSpoolExtraPreservesKeys:
  1254. """Unit-level test for merge_spool_extra key preservation (via mocked Spoolman)."""
  1255. @pytest.mark.asyncio
  1256. @pytest.mark.integration
  1257. async def test_merge_preserves_unrelated_extra_keys(
  1258. self,
  1259. async_client: AsyncClient,
  1260. spoolman_settings,
  1261. mock_spoolman_client,
  1262. ):
  1263. """merge_spool_extra must deep-merge rather than overwrite the extra dict.
  1264. Seed extra={"custom_key": "keep_me", "tag": "old"}.
  1265. After merging {"tag": "new"}, the PATCH payload must still contain custom_key.
  1266. """
  1267. from unittest.mock import AsyncMock, patch
  1268. existing_spool = {
  1269. **SAMPLE_SPOOLMAN_SPOOL,
  1270. "extra": {"custom_key": "keep_me", "tag": '"old"'},
  1271. }
  1272. updated_spool = {**existing_spool, "extra": {"custom_key": "keep_me", "tag": '"new"'}}
  1273. mock_client = mock_spoolman_client
  1274. mock_client.get_spool = AsyncMock(return_value=existing_spool)
  1275. mock_client.update_spool_full = AsyncMock(return_value=updated_spool)
  1276. # Call merge_spool_extra directly through the service
  1277. from backend.app.services.spoolman import SpoolmanClient
  1278. client = SpoolmanClient.__new__(SpoolmanClient)
  1279. client.base_url = "http://localhost:7912"
  1280. client.api_url = "http://localhost:7912/api/v1"
  1281. client._extra_locks = {}
  1282. async def _mock_get(spool_id):
  1283. return existing_spool
  1284. async def _mock_update(spool_id, **kwargs):
  1285. # Capture what was actually sent
  1286. _mock_update.captured_extra = kwargs.get("extra")
  1287. return updated_spool
  1288. _mock_update.captured_extra = None
  1289. client.get_spool = _mock_get
  1290. client.update_spool_full = _mock_update
  1291. result = await client.merge_spool_extra(42, {"tag": '"new"'})
  1292. # The merged extra must include the unrelated key
  1293. assert _mock_update.captured_extra is not None
  1294. assert _mock_update.captured_extra.get("custom_key") == "keep_me"
  1295. assert _mock_update.captured_extra.get("tag") == '"new"'
  1296. assert result is not None
  1297. class TestGetClientValueError:
  1298. """Test the ValueError branch in _get_client when init_spoolman_client fails (Gap 5)."""
  1299. @pytest.mark.asyncio
  1300. @pytest.mark.integration
  1301. async def test_returns_400_when_init_spoolman_client_raises_value_error(
  1302. self, async_client: AsyncClient, spoolman_settings
  1303. ):
  1304. """If init_spoolman_client raises ValueError after SSRF check passes, return HTTP 400."""
  1305. with (
  1306. patch(
  1307. "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
  1308. AsyncMock(return_value=None),
  1309. ),
  1310. patch(
  1311. "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
  1312. AsyncMock(side_effect=ValueError("unsupported scheme")),
  1313. ),
  1314. ):
  1315. resp = await async_client.get("/api/v1/spoolman/inventory/spools")
  1316. assert resp.status_code == 400
  1317. assert "unsupported scheme" in resp.json()["detail"]
  1318. class TestBulkCreateWithPriceFailure:
  1319. """Test that bulk create succeeds even when price update fails (Gap 6)."""
  1320. @pytest.mark.asyncio
  1321. @pytest.mark.integration
  1322. async def test_bulk_create_succeeds_when_price_update_fails(
  1323. self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
  1324. ):
  1325. """Bulk create returns 200 even if update_spool_full (price) raises SpoolmanUnavailableError."""
  1326. from backend.app.services.spoolman import SpoolmanUnavailableError
  1327. mock_spoolman_client.update_spool_full = AsyncMock(side_effect=SpoolmanUnavailableError("price server down"))
  1328. mock_spoolman_client.create_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
  1329. payload = {
  1330. "spool": {
  1331. "material": "PLA",
  1332. "brand": "Bambu Lab",
  1333. "label_weight": 1000,
  1334. "cost_per_kg": 19.99,
  1335. },
  1336. "quantity": 2,
  1337. }
  1338. resp = await async_client.post("/api/v1/spoolman/inventory/spools/bulk", json=payload)
  1339. # Price update failure must not abort the bulk create
  1340. assert resp.status_code in (200, 207)
  1341. # Both spools must have been created
  1342. assert mock_spoolman_client.create_spool.call_count == 2
  1343. # Price update was attempted for each
  1344. assert mock_spoolman_client.update_spool_full.call_count == 2