Files
cista-storage/tests/test_sso_cache.py
T
LeoVasanko 7a0e473fb4 Drop watch websockets on session loss, purge SSO cache on logout
- Successful SSO /auth/api/validate responses are cached per credential
  and perm/renew URL for 10s, so watch websocket re-checks do not hammer
  the auth backend. A POST to the logout endpoint purges all cached
  entries for the request's credentials immediately, so logout/login
  flows are not served stale successes.
- The watch websocket now re-validates auth before each forwarded
  message and every 10s when idle (SSO and built-in sessions alike).
  When the session is gone the client gets an auth error message and
  the socket is closed, instead of streaming updates forever.
- Token-authenticated (API/share token) sockets are exempt from
  re-validation; they are checked once at handshake.
2026-08-12 21:20:12 +00:00

143 lines
4.5 KiB
Python

from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from sanic.exceptions import Unauthorized
from cista import sso
def _make_request(cookie: str = "", authorization: str = ""):
req = SimpleNamespace()
req.headers = {}
if cookie:
req.headers["cookie"] = cookie
if authorization:
req.headers["authorization"] = authorization
req.client_ip = "127.0.0.1"
req.host = "test.local"
req.scheme = "http"
req.ctx = SimpleNamespace()
return req
@pytest.fixture(autouse=True)
def _reset_sso_cache_and_client(monkeypatch):
"""Clear the SSO validation cache and shared client between tests."""
sso._validate_cache.clear()
sso._client = None
monkeypatch.setenv("PASKIA_BACKEND_URL", "http://test-paskia.local")
monkeypatch.setattr(sso, "PASKIA_BACKEND_URL", "http://test-paskia.local")
yield
sso._validate_cache.clear()
@pytest.fixture
def mock_client(monkeypatch):
client = AsyncMock()
client.is_closed = False
client.headers = {}
monkeypatch.setattr(sso, "_client", client)
return client
@pytest.mark.asyncio
async def test_validate_sso_request_caches_successful_responses(mock_client):
req = _make_request(cookie="session=abc123")
mock_client.post.return_value = httpx.Response(200, json={"user": "alice"})
data1 = await sso.validate_sso_request(req)
data2 = await sso.validate_sso_request(req)
assert data1 == {"user": "alice"}
assert data2 == data1
assert mock_client.post.call_count == 1
assert req.ctx.sso_user == {"user": "alice"}
@pytest.mark.asyncio
async def test_validate_sso_request_does_not_cache_errors(mock_client):
req = _make_request(cookie="session=bad")
mock_client.post.return_value = httpx.Response(401, json={"detail": "nope"})
with pytest.raises(Unauthorized):
await sso.validate_sso_request(req)
with pytest.raises(Unauthorized):
await sso.validate_sso_request(req)
assert mock_client.post.call_count == 2
@pytest.mark.asyncio
async def test_validate_sso_request_cache_is_per_credential(mock_client):
req_alice = _make_request(cookie="session=alice")
req_bob = _make_request(cookie="session=bob")
responses = {
"alice": httpx.Response(200, json={"user": "alice"}),
"bob": httpx.Response(200, json={"user": "bob"}),
}
def side_effect(*args, **kwargs):
cookie = kwargs.get("headers", {}).get("cookie", "")
if "alice" in cookie:
return responses["alice"]
return responses["bob"]
mock_client.post.side_effect = side_effect
assert await sso.validate_sso_request(req_alice) == {"user": "alice"}
assert await sso.validate_sso_request(req_bob) == {"user": "bob"}
assert await sso.validate_sso_request(req_alice) == {"user": "alice"}
assert mock_client.post.call_count == 2
@pytest.mark.asyncio
async def test_validate_sso_request_cache_is_per_permission(mock_client):
req = _make_request(cookie="session=abc123")
mock_client.post.return_value = httpx.Response(200, json={"user": "alice"})
await sso.validate_sso_request(req, perm="cista:login")
await sso.validate_sso_request(req, perm="cista:admin")
assert mock_client.post.call_count == 2
@pytest.mark.asyncio
async def test_invalidate_validation_cache_forces_backend_call(mock_client):
req = _make_request(cookie="session=abc123")
mock_client.post.return_value = httpx.Response(200, json={"user": "alice"})
await sso.validate_sso_request(req)
sso.invalidate_validation_cache(req)
await sso.validate_sso_request(req)
assert mock_client.post.call_count == 2
@pytest.mark.asyncio
async def test_invalidate_validation_cache_only_affects_same_credentials(mock_client):
alice = _make_request(cookie="session=alice")
bob = _make_request(cookie="session=bob")
responses = {
"alice": httpx.Response(200, json={"user": "alice"}),
"bob": httpx.Response(200, json={"user": "bob"}),
}
def side_effect(*args, **kwargs):
cookie = kwargs.get("headers", {}).get("cookie", "")
return responses["alice"] if "alice" in cookie else responses["bob"]
mock_client.post.side_effect = side_effect
await sso.validate_sso_request(alice)
await sso.validate_sso_request(bob)
sso.invalidate_validation_cache(alice)
assert await sso.validate_sso_request(alice) == {"user": "alice"}
assert await sso.validate_sso_request(bob) == {"user": "bob"}
# Alice is re-fetched; bob is still cached.
assert mock_client.post.call_count == 3