| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- """``connected`` describes Spoolman, not this process's memory (issue #2903).
- ``GET /spoolman/status`` used to report ``connected`` by looking for a client
- object left behind by some earlier request. Around twenty call sites build one
- lazily, so the answer turned on which page had been loaded rather than on
- anything about Spoolman -- and the Settings page builds one as a side effect of
- saving, which is how enabling the integration came to report "connected" before
- anything had been set up.
- The UI reads the flag twice, offering the Connect button only while
- disconnected and the AMS sync section only while connected, so an answer that
- depends on request ordering puts those two controls into states the user cannot
- predict or explain.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- @pytest.fixture
- async def spoolman_enabled(db_session):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
- await db_session.commit()
- @pytest.fixture
- async def spoolman_disabled_but_configured(db_session):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="spoolman_enabled", value="false"))
- db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
- await db_session.commit()
- def _client(*, healthy: bool = True, base_url: str = "http://localhost:7912") -> MagicMock:
- client = MagicMock()
- client.base_url = base_url
- client.health_check = AsyncMock(return_value=healthy)
- return client
- def _patch(get_returns, init_returns=None, init_side_effect=None):
- """Patch the route module's client accessors."""
- init = AsyncMock(return_value=init_returns, side_effect=init_side_effect)
- return (
- patch("backend.app.api.routes.spoolman.get_spoolman_client", AsyncMock(return_value=get_returns)),
- patch("backend.app.api.routes.spoolman.init_spoolman_client", init),
- init,
- )
- class TestItAsksSpoolmanRatherThanItself:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_it_reports_connected_without_a_prior_client(self, async_client: AsyncClient, spoolman_enabled):
- """Nothing has built a client yet -- the status must still be the truth."""
- healthy = _client()
- get_patch, init_patch, init = _patch(None, init_returns=healthy)
- with get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.status_code == 200
- assert response.json()["connected"] is True
- init.assert_awaited_once_with("http://localhost:7912")
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_it_asks_the_url_configured_now_not_the_one_cached(self, async_client: AsyncClient, spoolman_enabled):
- """A client left pointing at the previous URL must not answer for the new one."""
- stale = _client(base_url="http://old-host:7912")
- fresh = _client()
- get_patch, init_patch, init = _patch(stale, init_returns=fresh)
- with get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.json()["connected"] is True
- init.assert_awaited_once_with("http://localhost:7912")
- stale.health_check.assert_not_awaited()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_matching_client_is_reused(self, async_client: AsyncClient, spoolman_enabled):
- existing = _client()
- get_patch, init_patch, init = _patch(existing)
- with get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.json()["connected"] is True
- init.assert_not_awaited()
- existing.health_check.assert_awaited_once()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_unreachable_spoolman_reports_disconnected(self, async_client: AsyncClient, spoolman_enabled):
- """The Connect button is a retry affordance, so this is the case that shows it."""
- get_patch, init_patch, _ = _patch(_client(healthy=False))
- with get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.json() == {
- "enabled": True,
- "connected": False,
- "url": "http://localhost:7912",
- }
- class TestItStaysQuietWhenThereIsNothingToAsk:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_disabled_integration_is_never_probed(
- self, async_client: AsyncClient, spoolman_disabled_but_configured
- ):
- """A stale client used to make a switched-off integration report "Connected"."""
- leftover = _client()
- get_patch, init_patch, init = _patch(leftover)
- with get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.json()["enabled"] is False
- assert response.json()["connected"] is False
- leftover.health_check.assert_not_awaited()
- init.assert_not_awaited()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_url_configured_is_not_probed(self, async_client: AsyncClient, db_session):
- from backend.app.models.settings import Settings
- db_session.add(Settings(key="spoolman_enabled", value="true"))
- await db_session.commit()
- get_patch, init_patch, init = _patch(None)
- with get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.json()["connected"] is False
- init.assert_not_awaited()
- class TestWhenTheUrlCannotBeUsed:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_ssrf_rejected_url_reports_disconnected_rather_than_erroring(
- self, async_client: AsyncClient, spoolman_enabled
- ):
- """The guard raises ValueError; a status poll must not become a 500."""
- get_patch, init_patch, _ = _patch(None, init_side_effect=ValueError("blocked"))
- with get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.status_code == 200
- assert response.json()["connected"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_ssrf_rejection_says_so_rather_than_reading_as_a_generic_fault(
- self, async_client: AsyncClient, spoolman_enabled, caplog
- ):
- """A rejected URL is the admin's to fix, so the log has to name it.
- Both failure branches return the same body, so behaviour alone cannot
- tell them apart -- only the line each one logs can, and a URL the guard
- refuses needs different words from a client that would not open.
- """
- get_patch, init_patch, _ = _patch(None, init_side_effect=ValueError("blocked"))
- with caplog.at_level("WARNING"), get_patch, init_patch:
- await async_client.get("/api/v1/spoolman/status")
- assert "SSRF guard" in caplog.text
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_client_that_cannot_be_opened_reports_disconnected(
- self, async_client: AsyncClient, spoolman_enabled, caplog
- ):
- """Replacing a client closes the old one, and httpx's aclose() may raise.
- A poll that runs every 30 seconds must not answer 500 when it can
- answer the truth instead -- and must still say why in the log.
- """
- get_patch, init_patch, _ = _patch(None, init_side_effect=RuntimeError("event loop is closed"))
- with caplog.at_level("WARNING"), get_patch, init_patch:
- response = await async_client.get("/api/v1/spoolman/status")
- assert response.status_code == 200
- assert response.json()["connected"] is False
- assert "Could not open a Spoolman client" in caplog.text
- assert "SSRF guard" not in caplog.text
|