test_spoolman_status_2903.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """``connected`` describes Spoolman, not this process's memory (issue #2903).
  2. ``GET /spoolman/status`` used to report ``connected`` by looking for a client
  3. object left behind by some earlier request. Around twenty call sites build one
  4. lazily, so the answer turned on which page had been loaded rather than on
  5. anything about Spoolman -- and the Settings page builds one as a side effect of
  6. saving, which is how enabling the integration came to report "connected" before
  7. anything had been set up.
  8. The UI reads the flag twice, offering the Connect button only while
  9. disconnected and the AMS sync section only while connected, so an answer that
  10. depends on request ordering puts those two controls into states the user cannot
  11. predict or explain.
  12. """
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from httpx import AsyncClient
  16. @pytest.fixture
  17. async def spoolman_enabled(db_session):
  18. from backend.app.models.settings import Settings
  19. db_session.add(Settings(key="spoolman_enabled", value="true"))
  20. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  21. await db_session.commit()
  22. @pytest.fixture
  23. async def spoolman_disabled_but_configured(db_session):
  24. from backend.app.models.settings import Settings
  25. db_session.add(Settings(key="spoolman_enabled", value="false"))
  26. db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
  27. await db_session.commit()
  28. def _client(*, healthy: bool = True, base_url: str = "http://localhost:7912") -> MagicMock:
  29. client = MagicMock()
  30. client.base_url = base_url
  31. client.health_check = AsyncMock(return_value=healthy)
  32. return client
  33. def _patch(get_returns, init_returns=None, init_side_effect=None):
  34. """Patch the route module's client accessors."""
  35. init = AsyncMock(return_value=init_returns, side_effect=init_side_effect)
  36. return (
  37. patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=get_returns)),
  38. patch("backend.app.api.routes.spoolman.init_spoolman_client", init),
  39. init,
  40. )
  41. class TestItAsksSpoolmanRatherThanItself:
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. async def test_it_reports_connected_without_a_prior_client(self, async_client: AsyncClient, spoolman_enabled):
  45. """Nothing has built a client yet -- the status must still be the truth."""
  46. healthy = _client()
  47. get_patch, init_patch, init = _patch(None, init_returns=healthy)
  48. with get_patch, init_patch:
  49. response = await async_client.get("/api/v1/spoolman/status")
  50. assert response.status_code == 200
  51. assert response.json()["connected"] is True
  52. init.assert_awaited_once_with("http://localhost:7912")
  53. @pytest.mark.asyncio
  54. @pytest.mark.integration
  55. async def test_it_asks_the_url_configured_now_not_the_one_cached(self, async_client: AsyncClient, spoolman_enabled):
  56. """A client left pointing at the previous URL must not answer for the new one."""
  57. stale = _client(base_url="http://old-host:7912")
  58. fresh = _client()
  59. get_patch, init_patch, init = _patch(stale, init_returns=fresh)
  60. with get_patch, init_patch:
  61. response = await async_client.get("/api/v1/spoolman/status")
  62. assert response.json()["connected"] is True
  63. init.assert_awaited_once_with("http://localhost:7912")
  64. stale.health_check.assert_not_awaited()
  65. @pytest.mark.asyncio
  66. @pytest.mark.integration
  67. async def test_a_matching_client_is_reused(self, async_client: AsyncClient, spoolman_enabled):
  68. existing = _client()
  69. get_patch, init_patch, init = _patch(existing)
  70. with get_patch, init_patch:
  71. response = await async_client.get("/api/v1/spoolman/status")
  72. assert response.json()["connected"] is True
  73. init.assert_not_awaited()
  74. existing.health_check.assert_awaited_once()
  75. @pytest.mark.asyncio
  76. @pytest.mark.integration
  77. async def test_an_unreachable_spoolman_reports_disconnected(self, async_client: AsyncClient, spoolman_enabled):
  78. """The Connect button is a retry affordance, so this is the case that shows it."""
  79. get_patch, init_patch, _ = _patch(_client(healthy=False))
  80. with get_patch, init_patch:
  81. response = await async_client.get("/api/v1/spoolman/status")
  82. assert response.json() == {
  83. "enabled": True,
  84. "connected": False,
  85. "url": "http://localhost:7912",
  86. }
  87. class TestItStaysQuietWhenThereIsNothingToAsk:
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_a_disabled_integration_is_never_probed(
  91. self, async_client: AsyncClient, spoolman_disabled_but_configured
  92. ):
  93. """A stale client used to make a switched-off integration report "Connected"."""
  94. leftover = _client()
  95. get_patch, init_patch, init = _patch(leftover)
  96. with get_patch, init_patch:
  97. response = await async_client.get("/api/v1/spoolman/status")
  98. assert response.json()["enabled"] is False
  99. assert response.json()["connected"] is False
  100. leftover.health_check.assert_not_awaited()
  101. init.assert_not_awaited()
  102. @pytest.mark.asyncio
  103. @pytest.mark.integration
  104. async def test_no_url_configured_is_not_probed(self, async_client: AsyncClient, db_session):
  105. from backend.app.models.settings import Settings
  106. db_session.add(Settings(key="spoolman_enabled", value="true"))
  107. await db_session.commit()
  108. get_patch, init_patch, init = _patch(None)
  109. with get_patch, init_patch:
  110. response = await async_client.get("/api/v1/spoolman/status")
  111. assert response.json()["connected"] is False
  112. init.assert_not_awaited()
  113. class TestWhenTheUrlCannotBeUsed:
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_an_ssrf_rejected_url_reports_disconnected_rather_than_erroring(
  117. self, async_client: AsyncClient, spoolman_enabled
  118. ):
  119. """The guard raises ValueError; a status poll must not become a 500."""
  120. get_patch, init_patch, _ = _patch(None, init_side_effect=ValueError("blocked"))
  121. with get_patch, init_patch:
  122. response = await async_client.get("/api/v1/spoolman/status")
  123. assert response.status_code == 200
  124. assert response.json()["connected"] is False
  125. @pytest.mark.asyncio
  126. @pytest.mark.integration
  127. async def test_the_ssrf_rejection_says_so_rather_than_reading_as_a_generic_fault(
  128. self, async_client: AsyncClient, spoolman_enabled, caplog
  129. ):
  130. """A rejected URL is the admin's to fix, so the log has to name it.
  131. Both failure branches return the same body, so behaviour alone cannot
  132. tell them apart -- only the line each one logs can, and a URL the guard
  133. refuses needs different words from a client that would not open.
  134. """
  135. get_patch, init_patch, _ = _patch(None, init_side_effect=ValueError("blocked"))
  136. with caplog.at_level("WARNING"), get_patch, init_patch:
  137. await async_client.get("/api/v1/spoolman/status")
  138. assert "SSRF guard" in caplog.text
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_a_client_that_cannot_be_opened_reports_disconnected(
  142. self, async_client: AsyncClient, spoolman_enabled, caplog
  143. ):
  144. """Replacing a client closes the old one, and httpx's aclose() may raise.
  145. A poll that runs every 30 seconds must not answer 500 when it can
  146. answer the truth instead -- and must still say why in the log.
  147. """
  148. get_patch, init_patch, _ = _patch(None, init_side_effect=RuntimeError("event loop is closed"))
  149. with caplog.at_level("WARNING"), get_patch, init_patch:
  150. response = await async_client.get("/api/v1/spoolman/status")
  151. assert response.status_code == 200
  152. assert response.json()["connected"] is False
  153. assert "Could not open a Spoolman client" in caplog.text
  154. assert "SSRF guard" not in caplog.text