mirror of
https://github.com/kennethreitz/responder.git
synced 2026-07-21 18:39:29 +00:00
592 lines
19 KiB
Python
592 lines
19 KiB
Python
"""Real coverage for the Redis-backed session and rate-limit backends.
|
|
|
|
Earlier suites exercised these classes only through hand-rolled stub
|
|
clients; here `fakeredis` (sync + async, with Lua support via `lupa`)
|
|
runs the actual commands — including the atomic INCR+EXPIRE Lua script —
|
|
against an in-memory Redis emulation, plus its connection-error
|
|
simulation for outage behavior.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
import time
|
|
|
|
import pytest
|
|
|
|
import responder
|
|
from responder.ext.ratelimit import AsyncRedisBackend, RateLimiter, RedisBackend
|
|
from responder.ext.sessions import AsyncRedisSessionBackend, RedisSessionBackend
|
|
|
|
try:
|
|
import fakeredis
|
|
import fakeredis.aioredis
|
|
import redis.exceptions # fakeredis depends on redis-py
|
|
except ImportError: # pragma: no cover - exercised only without fakeredis
|
|
fakeredis = None
|
|
|
|
pytestmark = pytest.mark.skipif(fakeredis is None, reason="fakeredis not installed")
|
|
|
|
try:
|
|
import lupa # noqa: F401
|
|
|
|
HAS_LUA = True
|
|
except ImportError: # pragma: no cover - exercised only without lupa
|
|
HAS_LUA = False
|
|
|
|
needs_lua = pytest.mark.skipif(
|
|
not HAS_LUA, reason="fakeredis Lua (EVAL) support requires lupa"
|
|
)
|
|
|
|
|
|
def make_client():
|
|
return fakeredis.FakeRedis()
|
|
|
|
|
|
def make_down_client():
|
|
"""A client whose every command raises redis.exceptions.ConnectionError."""
|
|
server = fakeredis.FakeServer()
|
|
server.connected = False
|
|
return fakeredis.FakeRedis(server=server)
|
|
|
|
|
|
def make_async_down_client():
|
|
server = fakeredis.FakeServer()
|
|
server.connected = False
|
|
return fakeredis.aioredis.FakeRedis(server=server)
|
|
|
|
|
|
def make_api(**kwargs):
|
|
return responder.API(allowed_hosts=[";"], session_https_only=False, **kwargs)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# RedisSessionBackend (sync)
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_redis_session_set_get_delete_round_trip():
|
|
fake = make_client()
|
|
backend = RedisSessionBackend(client=fake)
|
|
|
|
assert backend.get("missing") is None
|
|
|
|
backend.set("sid", {"user": "kenneth", "n": 3}, max_age=60)
|
|
assert backend.get("sid") == {"user": "kenneth", "n": 3}
|
|
|
|
backend.delete("sid")
|
|
assert backend.get("sid") is None
|
|
# Deleting a missing session is a no-op, not an error.
|
|
backend.delete("sid")
|
|
|
|
|
|
def test_redis_session_set_uses_set_with_expiry_instead_of_deprecated_setex():
|
|
class FakeRedis:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.store = {}
|
|
|
|
def set(self, key, value, *, ex):
|
|
self.calls.append(("set", key, value, ex))
|
|
self.store[key] = value
|
|
|
|
def setex(self, key, ttl, value): # pragma: no cover - must not be called
|
|
self.calls.append(("setex", key, value, ttl))
|
|
|
|
def get(self, key):
|
|
return self.store.get(key)
|
|
|
|
fake = FakeRedis()
|
|
backend = RedisSessionBackend(client=fake)
|
|
|
|
backend.set("sid", {"user": "kenneth"}, max_age=60)
|
|
|
|
assert fake.calls == [
|
|
("set", "responder:session:sid", '{"user": "kenneth"}', 60)
|
|
]
|
|
assert backend.get("sid") == {"user": "kenneth"}
|
|
|
|
|
|
def test_redis_session_prefix():
|
|
fake = make_client()
|
|
|
|
RedisSessionBackend(client=fake).set("sid", {"a": 1}, max_age=60)
|
|
assert fake.exists("responder:session:sid")
|
|
|
|
RedisSessionBackend(client=fake, prefix="myapp:").set("sid2", {"b": 2}, max_age=60)
|
|
assert fake.exists("myapp:sid2")
|
|
# Prefixes isolate: the custom-prefix backend can't see the default's key.
|
|
assert RedisSessionBackend(client=fake, prefix="myapp:").get("sid") is None
|
|
|
|
|
|
def test_redis_session_ttl_set_and_touch():
|
|
fake = make_client()
|
|
backend = RedisSessionBackend(client=fake)
|
|
|
|
backend.set("sid", {"user": "k"}, max_age=100)
|
|
assert 0 < fake.ttl("responder:session:sid") <= 100
|
|
|
|
# touch slides the TTL without rewriting the payload.
|
|
backend.touch("sid", 500)
|
|
assert 100 < fake.ttl("responder:session:sid") <= 500
|
|
assert backend.get("sid") == {"user": "k"}
|
|
|
|
# Touching a missing session is a no-op.
|
|
backend.touch("missing", 500)
|
|
assert backend.get("missing") is None
|
|
|
|
|
|
def test_redis_session_expiry():
|
|
fake = make_client()
|
|
backend = RedisSessionBackend(client=fake)
|
|
|
|
backend.set("sid", {"user": "k"}, max_age=1)
|
|
assert backend.get("sid") == {"user": "k"}
|
|
time.sleep(1.1)
|
|
assert backend.get("sid") is None
|
|
|
|
|
|
def test_redis_session_serializer_round_trips_common_types():
|
|
"""8.1 contract: the default JSONSessionSerializer round-trips the
|
|
documented common types (datetime, date, set, Decimal, ...); tuples
|
|
still come back as lists (a JSON limitation).
|
|
"""
|
|
fake = make_client()
|
|
backend = RedisSessionBackend(client=fake)
|
|
|
|
backend.set("sid", {"pair": (1, 2)}, max_age=60)
|
|
assert backend.get("sid") == {"pair": [1, 2]}
|
|
|
|
import datetime
|
|
from decimal import Decimal
|
|
|
|
when = datetime.datetime(2026, 7, 3, 12, 0, tzinfo=datetime.timezone.utc)
|
|
backend.set(
|
|
"sid2",
|
|
{"when": when, "tags": {"a", "b"}, "price": Decimal("9.99")},
|
|
max_age=60,
|
|
)
|
|
restored = backend.get("sid2")
|
|
assert restored["when"] == when
|
|
assert restored["tags"] == {"a", "b"}
|
|
assert restored["price"] == Decimal("9.99")
|
|
|
|
|
|
def test_redis_session_middleware_end_to_end():
|
|
fake = make_client()
|
|
api = make_api(session_backend=RedisSessionBackend(client=fake))
|
|
|
|
@api.route("/login", methods=["POST"])
|
|
async def login(req, resp):
|
|
req.session["user"] = "kenneth"
|
|
resp.media = {"ok": True}
|
|
|
|
@api.route("/whoami")
|
|
async def whoami(req, resp):
|
|
resp.media = {"user": req.session.get("user")}
|
|
|
|
@api.route("/logout", methods=["POST"])
|
|
async def logout(req, resp):
|
|
req.session.clear()
|
|
resp.media = {"ok": True}
|
|
|
|
client = api.requests
|
|
assert client.get("/whoami").json() == {"user": None}
|
|
|
|
r = client.post("/login")
|
|
assert "responder_session=" in r.headers["set-cookie"]
|
|
assert client.get("/whoami").json() == {"user": "kenneth"}
|
|
|
|
# Data lives in Redis under the prefix, and the backend TTL is set.
|
|
(key,) = fake.keys("responder:session:*")
|
|
assert json.loads(fake.get(key)) == {"user": "kenneth"}
|
|
assert fake.ttl(key) > 0
|
|
|
|
r = client.post("/logout")
|
|
assert "Max-Age=0" in r.headers["set-cookie"]
|
|
assert client.get("/whoami").json() == {"user": None}
|
|
assert fake.keys("responder:session:*") == []
|
|
|
|
|
|
def test_redis_session_connection_error_propagates():
|
|
"""Current contract: a Redis outage propagates ConnectionError (fail-closed)."""
|
|
backend = RedisSessionBackend(client=make_down_client())
|
|
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
backend.get("sid")
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
backend.set("sid", {"a": 1}, max_age=60)
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
backend.delete("sid")
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
backend.touch("sid", 60)
|
|
|
|
|
|
def test_redis_session_outage_yields_500_for_cookie_bearing_requests():
|
|
"""End-to-end: with Redis down, a request presenting a session cookie 500s
|
|
before the handler runs (current fail-closed contract)."""
|
|
from starlette.testclient import TestClient
|
|
|
|
api = make_api(session_backend=RedisSessionBackend(client=make_down_client()))
|
|
|
|
@api.route("/")
|
|
async def index(req, resp):
|
|
resp.text = "ok"
|
|
|
|
client = TestClient(api, base_url="http://;", raise_server_exceptions=False)
|
|
# No cookie -> the backend is never consulted on the way in, and an
|
|
# untouched (empty) session is never written, so the request succeeds.
|
|
assert client.get("/").status_code == 200
|
|
# A presented cookie forces a backend read, which fails.
|
|
client.cookies.set("responder_session", "whatever")
|
|
r = client.get("/")
|
|
assert r.status_code == 500
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# AsyncRedisSessionBackend
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_async_redis_session_round_trip():
|
|
fake = fakeredis.aioredis.FakeRedis()
|
|
backend = AsyncRedisSessionBackend(client=fake)
|
|
|
|
async def scenario():
|
|
assert await backend.aget("missing") is None
|
|
|
|
await backend.aset("sid", {"user": "kenneth"}, max_age=100)
|
|
assert await backend.aget("sid") == {"user": "kenneth"}
|
|
assert 0 < await fake.ttl("responder:session:sid") <= 100
|
|
|
|
await backend.atouch("sid", 500)
|
|
assert 100 < await fake.ttl("responder:session:sid") <= 500
|
|
|
|
await backend.adelete("sid")
|
|
assert await backend.aget("sid") is None
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_async_redis_session_set_uses_set_with_expiry_instead_of_deprecated_setex():
|
|
class FakeAsyncRedis:
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.store = {}
|
|
|
|
async def set(self, key, value, *, ex):
|
|
self.calls.append(("set", key, value, ex))
|
|
self.store[key] = value
|
|
|
|
async def setex(self, key, ttl, value): # pragma: no cover - must not be called
|
|
self.calls.append(("setex", key, value, ttl))
|
|
|
|
async def get(self, key):
|
|
return self.store.get(key)
|
|
|
|
fake = FakeAsyncRedis()
|
|
backend = AsyncRedisSessionBackend(client=fake)
|
|
|
|
async def scenario():
|
|
await backend.aset("sid", {"user": "kenneth"}, max_age=60)
|
|
assert await backend.aget("sid") == {"user": "kenneth"}
|
|
|
|
asyncio.run(scenario())
|
|
|
|
assert fake.calls == [
|
|
("set", "responder:session:sid", '{"user": "kenneth"}', 60)
|
|
]
|
|
|
|
|
|
def test_async_redis_session_prefix():
|
|
fake = fakeredis.aioredis.FakeRedis()
|
|
backend = AsyncRedisSessionBackend(client=fake, prefix="myapp:")
|
|
|
|
async def scenario():
|
|
await backend.aset("sid", {"a": 1}, max_age=60)
|
|
assert await fake.exists("myapp:sid")
|
|
assert not await fake.exists("responder:session:sid")
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_async_redis_session_middleware_end_to_end():
|
|
fake = fakeredis.aioredis.FakeRedis()
|
|
api = make_api(session_backend=AsyncRedisSessionBackend(client=fake))
|
|
|
|
@api.route("/login", methods=["POST"])
|
|
async def login(req, resp):
|
|
req.session["cart"] = [1, 2, 3]
|
|
resp.media = {"ok": True}
|
|
|
|
@api.route("/cart")
|
|
async def cart(req, resp):
|
|
resp.media = {"cart": req.session.get("cart")}
|
|
|
|
client = api.requests
|
|
client.post("/login")
|
|
assert client.get("/cart").json() == {"cart": [1, 2, 3]}
|
|
|
|
|
|
def test_async_redis_session_connection_error_propagates():
|
|
backend = AsyncRedisSessionBackend(client=make_async_down_client())
|
|
|
|
async def scenario():
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
await backend.aget("sid")
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
await backend.aset("sid", {"a": 1}, max_age=60)
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
await backend.adelete("sid")
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
await backend.atouch("sid", 60)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Rate-limit RedisBackend (sync) — exercises the real Lua script
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _hit(backend, key, max_requests, period):
|
|
"""Unpack a backend hit as (allowed, remaining, reset_after).
|
|
|
|
Backends return ``(allowed, remaining, reset_after)``; tolerate the
|
|
legacy 2-tuple form (``reset_after=None``) so these tests track the
|
|
documented backend contract rather than one specific arity.
|
|
"""
|
|
result = backend.hit(key, max_requests, period)
|
|
if len(result) == 2:
|
|
return (*result, None)
|
|
return result
|
|
|
|
|
|
@needs_lua
|
|
def test_redis_ratelimit_hit_counting():
|
|
backend = RedisBackend(client=make_client())
|
|
|
|
assert _hit(backend, "1.2.3.4", 3, 60)[:2] == (True, 2)
|
|
assert _hit(backend, "1.2.3.4", 3, 60)[:2] == (True, 1)
|
|
assert _hit(backend, "1.2.3.4", 3, 60)[:2] == (True, 0)
|
|
assert _hit(backend, "1.2.3.4", 3, 60)[:2] == (False, 0)
|
|
# Denied hits keep counting: still denied.
|
|
assert _hit(backend, "1.2.3.4", 3, 60)[:2] == (False, 0)
|
|
# Keys are independent.
|
|
assert _hit(backend, "5.6.7.8", 3, 60)[:2] == (True, 2)
|
|
|
|
|
|
@needs_lua
|
|
def test_redis_ratelimit_reports_reset_after():
|
|
"""The third element of hit() is the window's remaining TTL, in seconds."""
|
|
backend = RedisBackend(client=make_client())
|
|
|
|
allowed, _, reset_after = _hit(backend, "1.2.3.4", 2, 60)
|
|
assert allowed
|
|
assert 0 < reset_after <= 60
|
|
|
|
# A denied hit still reports when the window resets.
|
|
_hit(backend, "1.2.3.4", 1, 60)
|
|
allowed, _, reset_after = _hit(backend, "1.2.3.4", 1, 60)
|
|
assert not allowed
|
|
assert 0 < reset_after <= 60
|
|
|
|
|
|
@needs_lua
|
|
def test_redis_ratelimit_first_hit_sets_ttl_and_later_hits_keep_it():
|
|
fake = make_client()
|
|
backend = RedisBackend(client=fake)
|
|
key = "responder:ratelimit:1.2.3.4"
|
|
|
|
backend.hit("1.2.3.4", max_requests=10, period=60)
|
|
assert 0 < fake.ttl(key) <= 60
|
|
|
|
# Shrink the TTL out-of-band; a subsequent hit must NOT re-arm it —
|
|
# the Lua script attaches EXPIRE only when the counter is created.
|
|
fake.expire(key, 5)
|
|
backend.hit("1.2.3.4", max_requests=10, period=60)
|
|
assert fake.ttl(key) <= 5
|
|
|
|
|
|
@needs_lua
|
|
def test_redis_ratelimit_window_reset():
|
|
fake = make_client()
|
|
backend = RedisBackend(client=fake)
|
|
|
|
assert _hit(backend, "1.2.3.4", 1, 1)[:2] == (True, 0)
|
|
assert _hit(backend, "1.2.3.4", 1, 1)[:2] == (False, 0)
|
|
time.sleep(1.1)
|
|
# The key expired with the window: the budget is fresh again.
|
|
assert _hit(backend, "1.2.3.4", 1, 1)[:2] == (True, 0)
|
|
|
|
|
|
@needs_lua
|
|
def test_redis_ratelimit_prefix():
|
|
fake = make_client()
|
|
RedisBackend(client=fake, prefix="myapp:rl:").hit("k", max_requests=5, period=60)
|
|
assert fake.exists("myapp:rl:k")
|
|
|
|
RedisBackend(client=fake).hit("k", max_requests=5, period=60)
|
|
assert fake.exists("responder:ratelimit:k")
|
|
|
|
|
|
@needs_lua
|
|
def test_redis_ratelimit_end_to_end():
|
|
api = make_api()
|
|
limiter = RateLimiter(requests=2, period=60, backend=RedisBackend(client=make_client()))
|
|
limiter.install(api)
|
|
|
|
@api.route("/")
|
|
async def index(req, resp):
|
|
resp.text = "ok"
|
|
|
|
client = api.requests
|
|
r = client.get("/")
|
|
assert r.status_code == 200
|
|
assert r.headers["X-RateLimit-Limit"] == "2"
|
|
assert r.headers["X-RateLimit-Remaining"] == "1"
|
|
# The Redis window TTL surfaces as X-RateLimit-Reset.
|
|
assert 0 < int(r.headers["X-RateLimit-Reset"]) <= 60
|
|
|
|
assert client.get("/").status_code == 200
|
|
|
|
r = client.get("/")
|
|
assert r.status_code == 429
|
|
assert r.headers["Retry-After"] == "60"
|
|
assert 0 < int(r.headers["X-RateLimit-Reset"]) <= 60
|
|
body = r.json()
|
|
assert body["title"] == "Too Many Requests"
|
|
assert body["detail"] == "Rate limit exceeded."
|
|
|
|
|
|
def test_redis_ratelimit_connection_error_propagates():
|
|
"""Current contract: a Redis outage propagates from hit() (fail-closed)."""
|
|
backend = RedisBackend(client=make_down_client())
|
|
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
backend.hit("1.2.3.4", max_requests=5, period=60)
|
|
|
|
|
|
def test_redis_ratelimit_outage_fails_closed_with_503():
|
|
"""End-to-end: with Redis down and fail_open=False (the default), every
|
|
rate-limited request answers 503 rather than slipping past the limit."""
|
|
api = make_api()
|
|
limiter = RateLimiter(
|
|
requests=5, period=60, backend=RedisBackend(client=make_down_client())
|
|
)
|
|
limiter.install(api)
|
|
|
|
@api.route("/")
|
|
async def index(req, resp):
|
|
resp.text = "never"
|
|
|
|
r = api.requests.get("/")
|
|
assert r.status_code == 503
|
|
assert r.headers["Retry-After"] == "60"
|
|
body = r.json()
|
|
assert body["title"] == "Service Unavailable"
|
|
assert body["detail"] == "Rate limit backend unavailable."
|
|
|
|
|
|
def test_redis_ratelimit_outage_fail_open_allows_requests():
|
|
"""End-to-end: with Redis down and fail_open=True, requests pass unlimited."""
|
|
api = make_api()
|
|
limiter = RateLimiter(
|
|
requests=1,
|
|
period=60,
|
|
backend=RedisBackend(client=make_down_client()),
|
|
fail_open=True,
|
|
)
|
|
limiter.install(api)
|
|
|
|
@api.route("/")
|
|
async def index(req, resp):
|
|
resp.text = "ok"
|
|
|
|
client = api.requests
|
|
assert client.get("/").status_code == 200
|
|
assert client.get("/").status_code == 200 # over the nominal budget, still up
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Rate-limit AsyncRedisBackend
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@needs_lua
|
|
def test_async_redis_ratelimit_hit_counting_and_ttl():
|
|
fake = fakeredis.aioredis.FakeRedis()
|
|
backend = AsyncRedisBackend(client=fake)
|
|
|
|
async def ahit(key, max_requests, period):
|
|
result = await backend.ahit(key, max_requests, period)
|
|
if len(result) == 2:
|
|
return (*result, None)
|
|
return result
|
|
|
|
async def scenario():
|
|
assert (await ahit("1.2.3.4", 2, 60))[:2] == (True, 1)
|
|
assert 0 < await fake.ttl("responder:ratelimit:1.2.3.4") <= 60
|
|
assert (await ahit("1.2.3.4", 2, 60))[:2] == (True, 0)
|
|
allowed, remaining, reset_after = await ahit("1.2.3.4", 2, 60)
|
|
assert (allowed, remaining) == (False, 0)
|
|
if reset_after is not None:
|
|
assert 0 < reset_after <= 60
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
@needs_lua
|
|
def test_async_redis_ratelimit_end_to_end():
|
|
api = make_api()
|
|
limiter = RateLimiter(
|
|
requests=1, period=60, backend=AsyncRedisBackend(client=fakeredis.aioredis.FakeRedis())
|
|
)
|
|
limiter.install(api)
|
|
|
|
@api.route("/")
|
|
async def index(req, resp):
|
|
resp.text = "ok"
|
|
|
|
client = api.requests
|
|
assert client.get("/").status_code == 200
|
|
assert client.get("/").status_code == 429
|
|
|
|
|
|
def test_async_redis_ratelimit_connection_error_propagates():
|
|
backend = AsyncRedisBackend(client=make_async_down_client())
|
|
|
|
async def scenario():
|
|
with pytest.raises(redis.exceptions.ConnectionError):
|
|
await backend.ahit("1.2.3.4", max_requests=5, period=60)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Missing-redis ImportError contract
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cls",
|
|
[RedisSessionBackend, AsyncRedisSessionBackend, RedisBackend, AsyncRedisBackend],
|
|
)
|
|
def test_backends_raise_import_error_without_redis(monkeypatch, cls):
|
|
"""Constructing without a client requires the redis package; the error
|
|
tells the user how to fix it."""
|
|
monkeypatch.setitem(sys.modules, "redis", None) # makes `import redis` fail
|
|
with pytest.raises(ImportError, match="pip install redis"):
|
|
cls()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"cls",
|
|
[RedisSessionBackend, AsyncRedisSessionBackend, RedisBackend, AsyncRedisBackend],
|
|
)
|
|
def test_backends_build_client_from_url(cls):
|
|
"""A url= (no client) builds a redis client without connecting."""
|
|
backend = cls(url="redis://localhost:1/0")
|
|
assert backend.client is not None
|
|
assert backend.prefix.endswith(":")
|