test_spoolman_status_2903.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  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. client.has_tag_api = AsyncMock(return_value=False)
  33. return client
  34. def _patch(get_returns, init_returns=None, init_side_effect=None):
  35. """Patch the route module's client accessors."""
  36. init = AsyncMock(return_value=init_returns, side_effect=init_side_effect)
  37. return (
  38. patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=get_returns)),
  39. patch("backend.app.api.routes.spoolman.init_spoolman_client", init),
  40. init,
  41. )
  42. class TestItAsksSpoolmanRatherThanItself:
  43. @pytest.mark.asyncio
  44. @pytest.mark.integration
  45. async def test_it_reports_connected_without_a_prior_client(self, async_client: AsyncClient, spoolman_enabled):
  46. """Nothing has built a client yet -- the status must still be the truth."""
  47. healthy = _client()
  48. get_patch, init_patch, init = _patch(None, init_returns=healthy)
  49. with get_patch, init_patch:
  50. response = await async_client.get("/api/v1/spoolman/status")
  51. assert response.status_code == 200
  52. assert response.json()["connected"] is True
  53. init.assert_awaited_once_with("http://localhost:7912")
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_it_asks_the_url_configured_now_not_the_one_cached(self, async_client: AsyncClient, spoolman_enabled):
  57. """A client left pointing at the previous URL must not answer for the new one."""
  58. stale = _client(base_url="http://old-host:7912")
  59. fresh = _client()
  60. get_patch, init_patch, init = _patch(stale, init_returns=fresh)
  61. with get_patch, init_patch:
  62. response = await async_client.get("/api/v1/spoolman/status")
  63. assert response.json()["connected"] is True
  64. init.assert_awaited_once_with("http://localhost:7912")
  65. stale.health_check.assert_not_awaited()
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_a_matching_client_is_reused(self, async_client: AsyncClient, spoolman_enabled):
  69. existing = _client()
  70. get_patch, init_patch, init = _patch(existing)
  71. with get_patch, init_patch:
  72. response = await async_client.get("/api/v1/spoolman/status")
  73. assert response.json()["connected"] is True
  74. init.assert_not_awaited()
  75. existing.health_check.assert_awaited_once()
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_an_unreachable_spoolman_reports_disconnected(self, async_client: AsyncClient, spoolman_enabled):
  79. """The Connect button is a retry affordance, so this is the case that shows it."""
  80. get_patch, init_patch, _ = _patch(_client(healthy=False))
  81. with get_patch, init_patch:
  82. response = await async_client.get("/api/v1/spoolman/status")
  83. assert response.json() == {
  84. "enabled": True,
  85. "connected": False,
  86. "url": "http://localhost:7912",
  87. "native_tags": False,
  88. }
  89. class TestItStaysQuietWhenThereIsNothingToAsk:
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_a_disabled_integration_is_never_probed(
  93. self, async_client: AsyncClient, spoolman_disabled_but_configured
  94. ):
  95. """A stale client used to make a switched-off integration report "Connected"."""
  96. leftover = _client()
  97. get_patch, init_patch, init = _patch(leftover)
  98. with get_patch, init_patch:
  99. response = await async_client.get("/api/v1/spoolman/status")
  100. assert response.json()["enabled"] is False
  101. assert response.json()["connected"] is False
  102. leftover.health_check.assert_not_awaited()
  103. init.assert_not_awaited()
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_no_url_configured_is_not_probed(self, async_client: AsyncClient, db_session):
  107. from backend.app.models.settings import Settings
  108. db_session.add(Settings(key="spoolman_enabled", value="true"))
  109. await db_session.commit()
  110. get_patch, init_patch, init = _patch(None)
  111. with get_patch, init_patch:
  112. response = await async_client.get("/api/v1/spoolman/status")
  113. assert response.json()["connected"] is False
  114. init.assert_not_awaited()
  115. class TestWhenTheUrlCannotBeUsed:
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_an_ssrf_rejected_url_reports_disconnected_rather_than_erroring(
  119. self, async_client: AsyncClient, spoolman_enabled
  120. ):
  121. """The guard raises ValueError; a status poll must not become a 500."""
  122. get_patch, init_patch, _ = _patch(None, init_side_effect=ValueError("blocked"))
  123. with get_patch, init_patch:
  124. response = await async_client.get("/api/v1/spoolman/status")
  125. assert response.status_code == 200
  126. assert response.json()["connected"] is False
  127. @pytest.mark.asyncio
  128. @pytest.mark.integration
  129. async def test_the_ssrf_rejection_says_so_rather_than_reading_as_a_generic_fault(
  130. self, async_client: AsyncClient, spoolman_enabled, caplog
  131. ):
  132. """A rejected URL is the admin's to fix, so the log has to name it.
  133. Both failure branches return the same body, so behaviour alone cannot
  134. tell them apart -- only the line each one logs can, and a URL the guard
  135. refuses needs different words from a client that would not open.
  136. """
  137. get_patch, init_patch, _ = _patch(None, init_side_effect=ValueError("blocked"))
  138. with caplog.at_level("WARNING"), get_patch, init_patch:
  139. await async_client.get("/api/v1/spoolman/status")
  140. assert "SSRF guard" in caplog.text
  141. @pytest.mark.asyncio
  142. @pytest.mark.integration
  143. async def test_a_client_that_cannot_be_opened_reports_disconnected(
  144. self, async_client: AsyncClient, spoolman_enabled, caplog
  145. ):
  146. """Replacing a client closes the old one, and httpx's aclose() may raise.
  147. A poll that runs every 30 seconds must not answer 500 when it can
  148. answer the truth instead -- and must still say why in the log.
  149. """
  150. get_patch, init_patch, _ = _patch(None, init_side_effect=RuntimeError("event loop is closed"))
  151. with caplog.at_level("WARNING"), get_patch, init_patch:
  152. response = await async_client.get("/api/v1/spoolman/status")
  153. assert response.status_code == 200
  154. assert response.json()["connected"] is False
  155. assert "Could not open a Spoolman client" in caplog.text
  156. assert "SSRF guard" not in caplog.text