diff --git a/changelog.d/384.security.md b/changelog.d/384.security.md new file mode 100644 index 0000000..9696850 --- /dev/null +++ b/changelog.d/384.security.md @@ -0,0 +1,5 @@ +**The session examples no longer fall back to a fixed placeholder key.** With +`SESSION_SECRET_KEY` unset they emitted valid sessions signed with a key +published in the repository, so a copied example that reached production +without the variable accepted forged tokens. They now warn and sign with a +random key made up for that run. diff --git a/changelog.d/386.fixed.md b/changelog.d/386.fixed.md new file mode 100644 index 0000000..2bdb9d8 --- /dev/null +++ b/changelog.d/386.fixed.md @@ -0,0 +1,7 @@ +**Clearer warnings.** The `FutureWarning` from `get_session_manager()` +now says whether `SessionManagerProxy` is empty or holds a different manager, +the `UserWarning` for a `__Host-`/`__Secure-` cookie name the browser would +refuse links to the 0.4.0 issue, and the Memcached `RuntimeWarning`s of +`clear()`, `clear_path()`, `clear_pattern()` and `get_all_keys()` name the +application's line when raised through `CacheManager` or `SessionManager`, +rather than a line in the library. diff --git a/docs/APP_CACHE.md b/docs/APP_CACHE.md index 08705fd..4fee405 100644 --- a/docs/APP_CACHE.md +++ b/docs/APP_CACHE.md @@ -83,13 +83,15 @@ Complete runnable example: [`examples/app_cache.py`](https://github.com/allen009 first time it is used; `CacheManagerProxy.set()` registers your own instead. > [!NOTE] -> `clear()`/`clear_prefix()` are implemented via the backend's `get_all_keys()` +> `CacheManager.clear()`/`clear_prefix()` are implemented via the backend's `get_all_keys()` > and `delete_many()` (`DEL` in batches of 100 keys on Redis). Since Memcached doesn't > support key enumeration (see [Backends](BACKENDS.md#memcached)), these -> methods — and `clear_pattern()` — are no-ops on a Memcached backend that +> methods — and `CacheManager.clear_pattern()` — are no-ops on a Memcached backend that > return 0 with a `RuntimeWarning`; > `get()`/`set()`/`add()`/`delete()`/`has()` work normally. Use Redis or the in-memory -> backend if you need bulk clearing. +> backend if you need bulk clearing. Do not fall back to the backend's own `clear()` +> on Memcached: `MemcachedBackend.clear()` issues `flush_all` and wipes the whole +> server, HTTP responses, sessions, locks and other applications' keys included. ## Stampede protection diff --git a/docs/BACKENDS.md b/docs/BACKENDS.md index 2ac9f06..9248752 100644 --- a/docs/BACKENDS.md +++ b/docs/BACKENDS.md @@ -71,6 +71,8 @@ BackendProxy.set(backend) - Uses SCAN instead of KEYS for safe production use (non-blocking) - Namespaced with `fastapi_cachex:` prefix by default; pass `key_prefix="myapp:cache:"` for multi-tenant scenarios +- `clear()`, `clear_pattern()` and `clear_path()` delete only keys under this backend's + `key_prefix`; other applications on the same server keep their keys - Only the pattern you pass to `clear_pattern()` is a glob. The key prefix and the path given to `clear_path()` are matched literally, so `*`, `?`, `[` or `]` in them cannot reach keys outside the prefix or miss the path @@ -185,7 +187,10 @@ BackendProxy.set(backend) To drop a cached route's entry after a write, call [`invalidate(request)`](HTTP_CACHING.md#invalidating-a-single-cached-route), which rebuilds the exact key -- `clear()` issues `flush_all`, which wipes the whole Memcached server, not just this namespace +- `backend.clear()` (`MemcachedBackend.clear()`) issues `flush_all`, which wipes the whole + Memcached server, not just this namespace. `CacheManager.clear()` is different: it + enumerates keys, so on Memcached it deletes nothing (see + [Application cache](APP_CACHE.md)) - A key Memcached would reject (over 250 bytes, whitespace, non-ASCII) is stored under its SHA-256 digest - A `ttl` whose expiry falls after 2038-01-19 raises `ValueError` (see diff --git a/examples/README.md b/examples/README.md index 02df51e..e01e929 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,10 +47,11 @@ In your own project, install the extras an example needs, for example ## Secrets -The session examples read their signing key from `SESSION_SECRET_KEY` and fall -back to an obvious development placeholder. The monitoring routes in -`http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` is set. Always set real, -random values outside local development: +The session examples read their signing key from `SESSION_SECRET_KEY`. When it +is unset they warn and sign with a random key made up for that run, so sessions +end when the process restarts and are not shared between workers. The +monitoring routes in `http_cache.py` stay closed until `CACHE_ADMIN_TOKEN` is +set. Always set real, random values outside local development: ```bash python -c "import secrets; print(secrets.token_urlsafe(48))" diff --git a/examples/session_api.py b/examples/session_api.py index 49b7549..55ceead 100644 --- a/examples/session_api.py +++ b/examples/session_api.py @@ -14,6 +14,7 @@ import os import secrets +import warnings from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -35,12 +36,27 @@ backend = MemoryBackend() BackendProxy.set(backend) + +def session_secret_key() -> str: + """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" + key = os.environ.get("SESSION_SECRET_KEY") + if key: + return key + warnings.warn( + "SESSION_SECRET_KEY is not set, so this run signs sessions with a " + "random key: they end when the process restarts and are not shared " + "between workers. Set SESSION_SECRET_KEY to a random value of at least " + "32 characters, e.g. the output of " + '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', + UserWarning, + stacklevel=2, + ) + return secrets.token_urlsafe(48) + + config = SessionConfig( - # At least 32 characters. Set a real random value in production, e.g. - # `python -c "import secrets; print(secrets.token_urlsafe(48))"`. - secret_key=os.environ.get( - "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" - ), + # At least 32 characters, from the environment; see session_secret_key(). + secret_key=session_secret_key(), session_ttl=3600, # 1 hour # Both cookie defaults change in 0.4.0, so set them explicitly. Over HTTPS # use cookie_name="__Host-session", cookie_https_only=True. diff --git a/examples/session_jwt.py b/examples/session_jwt.py index 5d9f700..a7fee4e 100644 --- a/examples/session_jwt.py +++ b/examples/session_jwt.py @@ -12,6 +12,7 @@ import os import secrets +import warnings from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -33,13 +34,28 @@ backend = MemoryBackend() BackendProxy.set(backend) + +def session_secret_key() -> str: + """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" + key = os.environ.get("SESSION_SECRET_KEY") + if key: + return key + warnings.warn( + "SESSION_SECRET_KEY is not set, so this run signs sessions with a " + "random key: they end when the process restarts and are not shared " + "between workers. Set SESSION_SECRET_KEY to a random value of at least " + "32 characters, e.g. the output of " + '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', + UserWarning, + stacklevel=2, + ) + return secrets.token_urlsafe(48) + + config = SessionConfig( # HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64), or - # SessionManager warns. Set a real random value in production, e.g. - # `python -c "import secrets; print(secrets.token_urlsafe(48))"`. - secret_key=os.environ.get( - "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" - ), + # SessionManager warns; see session_secret_key(). + secret_key=session_secret_key(), token_format="jwt", jwt_algorithm="HS256", # Optional: issued as `iss`/`aud` and checked on every request. diff --git a/examples/session_jwt_claims.py b/examples/session_jwt_claims.py index 2ce2244..db9db8c 100644 --- a/examples/session_jwt_claims.py +++ b/examples/session_jwt_claims.py @@ -16,6 +16,7 @@ # --8<-- [start:serializer] import os import secrets +import warnings from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import datetime @@ -130,15 +131,30 @@ def check_claims(self, payload: dict[str, Any]) -> None: # --8<-- [end:multi-tenant] + # --8<-- [start:setup] +def session_secret_key() -> str: + """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" + key = os.environ.get("SESSION_SECRET_KEY") + if key: + return key + warnings.warn( + "SESSION_SECRET_KEY is not set, so this run signs sessions with a " + "random key: they end when the process restarts and are not shared " + "between workers. Set SESSION_SECRET_KEY to a random value of at least " + "32 characters, e.g. the output of " + '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', + UserWarning, + stacklevel=2, + ) + return secrets.token_urlsafe(48) + + backend = MemoryBackend() config = SessionConfig( - # HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64). Set a real - # random value in production, e.g. - # `python -c "import secrets; print(secrets.token_urlsafe(48))"`. - secret_key=os.environ.get( - "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" - ), + # HS256 wants a key of at least 32 bytes (HS384: 48, HS512: 64); see + # session_secret_key(). + secret_key=session_secret_key(), token_format="jwt", jwt_algorithm="HS256", jwt_issuer="acme-corp", diff --git a/examples/session_login.py b/examples/session_login.py index 5f23a88..d06cfbc 100644 --- a/examples/session_login.py +++ b/examples/session_login.py @@ -16,6 +16,7 @@ import os import secrets +import warnings from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -36,12 +37,27 @@ backend = MemoryBackend() BackendProxy.set(backend) + +def session_secret_key() -> str: + """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" + key = os.environ.get("SESSION_SECRET_KEY") + if key: + return key + warnings.warn( + "SESSION_SECRET_KEY is not set, so this run signs sessions with a " + "random key: they end when the process restarts and are not shared " + "between workers. Set SESSION_SECRET_KEY to a random value of at least " + "32 characters, e.g. the output of " + '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', + UserWarning, + stacklevel=2, + ) + return secrets.token_urlsafe(48) + + config = SessionConfig( - # At least 32 characters. Set a real random value in production, e.g. - # `python -c "import secrets; print(secrets.token_urlsafe(48))"`. - secret_key=os.environ.get( - "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" - ), + # At least 32 characters, from the environment; see session_secret_key(). + secret_key=session_secret_key(), session_ttl=3600, # 0.4.0 changes both cookie defaults (to "__Host-session" with the Secure # flag), so set them explicitly. In production over HTTPS use diff --git a/examples/session_redis.py b/examples/session_redis.py index 40cee85..7a2c4c6 100644 --- a/examples/session_redis.py +++ b/examples/session_redis.py @@ -16,6 +16,7 @@ import os import secrets +import warnings from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import datetime @@ -46,12 +47,27 @@ key_prefix="fastapi_cachex_example:", ) + +def session_secret_key() -> str: + """Return SESSION_SECRET_KEY, or a random key for this run with a warning.""" + key = os.environ.get("SESSION_SECRET_KEY") + if key: + return key + warnings.warn( + "SESSION_SECRET_KEY is not set, so this run signs sessions with a " + "random key: they end when the process restarts and are not shared " + "between workers. Set SESSION_SECRET_KEY to a random value of at least " + "32 characters, e.g. the output of " + '`python -c "import secrets; print(secrets.token_urlsafe(48))"`.', + UserWarning, + stacklevel=2, + ) + return secrets.token_urlsafe(48) + + config = SessionConfig( - # At least 32 characters. Set a real random value in production, e.g. - # `python -c "import secrets; print(secrets.token_urlsafe(48))"`. - secret_key=os.environ.get( - "SESSION_SECRET_KEY", "dev-only-placeholder-change-me-before-deploying" - ), + # At least 32 characters, from the environment; see session_secret_key(). + secret_key=session_secret_key(), session_ttl=3600, sliding_expiration=True, sliding_threshold=0.5, diff --git a/fastapi_cachex/backends/memcached.py b/fastapi_cachex/backends/memcached.py index 898f67e..764d8e6 100644 --- a/fastapi_cachex/backends/memcached.py +++ b/fastapi_cachex/backends/memcached.py @@ -2,6 +2,7 @@ import asyncio import hashlib +import inspect import logging import time import warnings @@ -68,6 +69,26 @@ def _expiry(ttl: int | None) -> int: return ttl +def _caller_stacklevel() -> int: + """Return the ``stacklevel`` of the first frame outside fastapi_cachex. + + For a ``warnings.warn`` in the method that calls this, so a warning raised + through ``CacheManager`` names the application's line, not manager.py. + """ + frame = inspect.currentframe() + if frame is None or frame.f_back is None: # no frame support + return 2 + # Level 1 is the method that warns; start at its caller. + level, frame = 2, frame.f_back.f_back + while frame is not None: + module = frame.f_globals.get("__name__", "") + if module != "fastapi_cachex" and not module.startswith("fastapi_cachex."): + break + frame = frame.f_back + level += 1 + return level + + class MemcachedBackend(BaseCacheBackend): """Memcached backend implementation. @@ -426,7 +447,7 @@ async def clear(self) -> None: "this namespace cannot be cleared on its own; delete known keys " "with delete() or delete_many() instead.", RuntimeWarning, - stacklevel=2, + stacklevel=_caller_stacklevel(), ) await asyncio.to_thread(self.client.flush_all) logger.debug("Memcached CLEAR; flush_all issued") @@ -456,7 +477,7 @@ async def clear_path(self, path: str, include_params: bool = False) -> int: "exactly as the path, and include_params has no effect. Use " "invalidate(request) to drop a cached route's entry.", RuntimeWarning, - stacklevel=2, + stacklevel=_caller_stacklevel(), ) # Try to delete the prefixed key (exact match only) @@ -490,7 +511,7 @@ async def clear_pattern(self, pattern: str) -> int: "Consider using Redis backend for pattern support, " "or track keys manually in your application logic.", RuntimeWarning, - stacklevel=2, + stacklevel=_caller_stacklevel(), ) logger.debug("Memcached CLEAR_PATTERN unsupported; pattern=%s", pattern) return 0 @@ -512,7 +533,7 @@ async def get_all_keys(self) -> list[str]: "Consider using Redis backend if you need cache monitoring, " "or track keys manually in your application.", RuntimeWarning, - stacklevel=2, + stacklevel=_caller_stacklevel(), ) logger.debug("Memcached GET_ALL_KEYS unsupported; returning empty list") return [] @@ -531,6 +552,8 @@ async def get_cache_data(self) -> dict[str, tuple[CacheEntry, float | None]]: "get_cache_data() returns an empty dictionary. " "Consider using Redis backend if you need cache monitoring.", RuntimeWarning, + # Called by the monitoring route, whose caller is FastAPI itself: + # routes.py names the source better than any frame outside it. stacklevel=2, ) logger.debug("Memcached GET_CACHE_DATA unsupported; returning empty dict") diff --git a/fastapi_cachex/session/config.py b/fastapi_cachex/session/config.py index 6cb5622..bd6c9ed 100644 --- a/fastapi_cachex/session/config.py +++ b/fastapi_cachex/session/config.py @@ -273,7 +273,8 @@ def _warn_invalid_cookie_prefix(self) -> "SessionConfig": f"cookie_name={self.cookie_name!r} requires {', '.join(problems)}: " "browsers refuse a cookie with this prefix otherwise, so the " "session cookie would never be stored. Version 0.4.0 will reject " - "this configuration.", + "this configuration " + "(https://github.com/allen0099/FastAPI-CacheX/issues/256).", UserWarning, stacklevel=3, ) diff --git a/fastapi_cachex/session/dependencies.py b/fastapi_cachex/session/dependencies.py index f424ee2..7199076 100644 --- a/fastapi_cachex/session/dependencies.py +++ b/fastapi_cachex/session/dependencies.py @@ -176,14 +176,19 @@ async def login( "FastAPICacheXSessionMiddleware is added to the app." ), ) - if not getattr(state, _PROXY_WARNED, False) and manager is not _proxy_manager(): + proxy_manager = _proxy_manager() + if not getattr(state, _PROXY_WARNED, False) and manager is not proxy_manager: setattr(state, _PROXY_WARNED, True) + registered = ( + "no SessionManager is set in SessionManagerProxy" + if proxy_manager is None + else "a different SessionManager is set in SessionManagerProxy" + ) warnings.warn( "get_session_manager() returned the SessionManager the session " - "middleware registered, which is not the one set in " - "SessionManagerProxy. Version 0.4.0 resolves get_session_manager() " - "(and SessionManagerDep, ClientIPDep and rotate_session_id(), which use " - "it) through SessionManagerProxy " + f"middleware registered, but {registered}. Version 0.4.0 resolves " + "get_session_manager() (and SessionManagerDep, ClientIPDep and " + "rotate_session_id(), which use it) through SessionManagerProxy " "only. Call SessionManagerProxy.set(session_manager) at startup " "(https://github.com/allen0099/FastAPI-CacheX/issues/131).", FutureWarning, diff --git a/i18n/zh-TW/docs/APP_CACHE.md b/i18n/zh-TW/docs/APP_CACHE.md index 39aacb3..d72b47b 100644 --- a/i18n/zh-TW/docs/APP_CACHE.md +++ b/i18n/zh-TW/docs/APP_CACHE.md @@ -52,7 +52,7 @@ await manager.clear_pattern("user:*") # 比對 "myapp:user:*" - `AppCache` 依賴項在第一次使用時會建立並註冊一個預設的 `CacheManager`;`CacheManagerProxy.set()` 則可改為註冊你自己的實例。 > [!NOTE] -> `clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是每批 100 個鍵的 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `clear_pattern()` 在 Memcached 後端上不會有任何作用,只會回傳 0 並發出 `RuntimeWarning`;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。 +> `CacheManager.clear()`/`clear_prefix()` 是以後端的 `get_all_keys()` 與 `delete_many()` 實作(在 Redis 上是每批 100 個鍵的 `DEL`)。由於 Memcached 不支援列舉鍵(見[後端](BACKENDS.md#memcached)),這些方法以及 `CacheManager.clear_pattern()` 在 Memcached 後端上不會有任何作用,只會回傳 0 並發出 `RuntimeWarning`;`get()`/`set()`/`add()`/`delete()`/`has()` 則照常運作。若需要大量清除,請使用 Redis 或記憶體後端。不要在 Memcached 上改用後端本身的 `clear()`:`MemcachedBackend.clear()` 會發出 `flush_all`,清空整台伺服器,包括 HTTP 回應、Session、鎖以及其他應用程式的鍵。 ## Cache stampede 保護 {#stampede-protection} diff --git a/i18n/zh-TW/docs/BACKENDS.md b/i18n/zh-TW/docs/BACKENDS.md index dcd305c..c5ecc19 100644 --- a/i18n/zh-TW/docs/BACKENDS.md +++ b/i18n/zh-TW/docs/BACKENDS.md @@ -50,6 +50,7 @@ BackendProxy.set(backend) - 支援依模式清除鍵 - 使用 SCAN 而非 KEYS,可安全用於正式環境(不會阻塞) - 預設以 `fastapi_cachex:` 前綴建立命名空間;多租戶情境可傳入 `key_prefix="myapp:cache:"` +- `clear()`、`clear_pattern()` 與 `clear_path()` 只刪除這個後端 `key_prefix` 之下的鍵;同一台伺服器上其他應用程式的鍵不受影響 - 只有傳給 `clear_pattern()` 的模式是萬用字元(glob)模式。鍵前綴與傳給 `clear_path()` 的路徑都以字面值比對,因此其中的 `*`、`?`、`[` 或 `]` 不會觸及前綴以外的鍵,也不會漏掉該路徑 - `clear_pattern()` 比對的是邏輯鍵,也就是不含後端前綴的鍵,並一律自行加上前綴。0.3.8 以前,以前綴開頭的模式會先去掉前綴再比對。在只有這種寫法能比對到項目時,它仍可使用,但會發出 `DeprecationWarning`,直到 0.4.0 為止 @@ -127,7 +128,7 @@ BackendProxy.set(backend) - Memcached 協定不支援依模式清除鍵(`clear_pattern`):它會回傳 0 並發出 `RuntimeWarning` - 無法列舉鍵:`get_all_keys()`/`get_cache_data()` 會回傳空結果(並發出 `RuntimeWarning`),因此監控路由不會顯示任何內容 - `clear_path()` 找不到 HTTP 快取項目:它只會刪除名稱與路徑完全相同的鍵,忽略 `include_params`,而且每次呼叫都會發出 `RuntimeWarning`。資料變更後要刪除某個快取路由的項目,請呼叫 [`invalidate(request)`](HTTP_CACHING.md#invalidating-a-single-cached-route),它會重建完全相同的鍵 -- `clear()` 會發出 `flush_all`,清空整台 Memcached 伺服器,而不只是這個命名空間 +- `backend.clear()`(`MemcachedBackend.clear()`)會發出 `flush_all`,清空整台 Memcached 伺服器,而不只是這個命名空間。`CacheManager.clear()` 則不同:它需要列舉鍵,因此在 Memcached 上不會刪除任何東西(見[應用程式快取](APP_CACHE.md)) - Memcached 會拒絕的鍵(超過 250 位元組、含空白字元或非 ASCII 字元)會改以其 SHA-256 摘要儲存 - 過期時間落在 2038-01-19 之後的 `ttl` 會拋出 `ValueError`(見 [TTL 值](#ttl-values)) - 超過伺服器項目大小上限(預設 1 MB,可用 `memcached -I` 調整)的值會被拒絕並拋出錯誤。`@cache` 會記錄該錯誤,並照常送出不儲存的回應(見[後端發生錯誤時](HTTP_CACHING.md#when-the-backend-fails));其他呼叫端則會收到該錯誤 diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index 017be1c..38f4b03 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -1,4 +1,5 @@ import asyncio +import inspect import socket import sys from unittest.mock import MagicMock @@ -16,9 +17,11 @@ from fastapi_cachex.backends.codec import encode_entry from fastapi_cachex.backends.memcached import _CAS_MAX_RETRIES from fastapi_cachex.backends.memcached import _DEAD_TIMEOUT +from fastapi_cachex.backends.memcached import _caller_stacklevel from fastapi_cachex.backends.memcached import _expiry from fastapi_cachex.exceptions import CacheXError from fastapi_cachex.lock import CacheLock +from fastapi_cachex.manager import CacheManager from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import counter_entry from tests.live_servers import MEMCACHED_SERVER @@ -1144,3 +1147,36 @@ async def product() -> dict[str, float]: ) assert await invalidate(request) is True assert client.get("/products/1").json() == {"price": 5.0} + + +async def test_unsupported_operation_warnings_name_the_callers_line() -> None: + backend = stubbed_backend() + + with pytest.warns(RuntimeWarning, match="pattern matching") as record: + await backend.clear_pattern("users:*") + assert record[0].filename == __file__ + + with pytest.warns(RuntimeWarning, match="key enumeration") as record: + await backend.get_all_keys() + assert record[0].filename == __file__ + + +async def test_warnings_through_cache_manager_name_the_applications_line() -> None: + # Called through CacheManager, the warning skips the library's frames + # instead of pointing at manager.py. + manager = CacheManager(stubbed_backend()) + + with pytest.warns(RuntimeWarning, match="pattern matching") as record: + assert await manager.clear_pattern("users:*") == 0 + assert record[0].filename == __file__ + + with pytest.warns(RuntimeWarning, match="key enumeration") as record: + assert await manager.clear() == 0 + assert record[0].filename == __file__ + + +def test_caller_stacklevel_without_frame_support( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(inspect, "currentframe", lambda: None) + assert _caller_stacklevel() == 2 diff --git a/tests/session/test_0_4_0_notices.py b/tests/session/test_0_4_0_notices.py index 6e1043b..029e5a6 100644 --- a/tests/session/test_0_4_0_notices.py +++ b/tests/session/test_0_4_0_notices.py @@ -127,7 +127,9 @@ def test_prefixed_cookie_names_browsers_refuse_warn( with pytest.warns(UserWarning, match="Version 0.4.0 will reject") as record: SessionConfig(secret_key=SECRET, **settings) - assert requirement in str(record[0].message) + message = str(record[0].message) + assert requirement in message + assert "issues/256" in message @pytest.mark.parametrize( @@ -205,13 +207,28 @@ def test_get_session_manager_warns_when_the_proxy_holds_another_manager( SessionManagerProxy.set(SessionManager(MemoryBackend(), config)) client = TestClient(_manager_app(manager, config)) - with pytest.warns(FutureWarning, match="not the one set in SessionManagerProxy"): + with pytest.warns( + FutureWarning, match="a different SessionManager is set in SessionManagerProxy" + ): response = client.get("/manager") # 0.3.x still answers with the middleware's manager. assert response.json() == {"is_same": True} +def test_get_session_manager_warns_when_the_proxy_is_empty( + manager: SessionManager, config: SessionConfig +) -> None: + client = TestClient(_manager_app(manager, config)) + + with pytest.warns( + FutureWarning, match="no SessionManager is set in SessionManagerProxy" + ): + response = client.get("/manager") + + assert response.json() == {"is_same": True} + + def test_get_session_manager_is_silent_when_the_proxy_agrees( manager: SessionManager, config: SessionConfig ) -> None: diff --git a/tests/test_examples.py b/tests/test_examples.py index 24bd633..afa5c64 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -49,6 +49,12 @@ def _reset_proxies() -> Iterator[None]: proxy.set(None) +@pytest.fixture(autouse=True) +def _session_secret_key(monkeypatch: pytest.MonkeyPatch) -> None: + """Without SESSION_SECRET_KEY the session examples warn (an error here).""" + monkeypatch.setenv("SESSION_SECRET_KEY", "test-" + "k" * 43) + + def load_example(name: str) -> ModuleType: """Import `examples/.py` as a new module, so no state is shared.""" module_name = f"_cachex_example_{name}" @@ -284,6 +290,40 @@ def test_session_login_response_is_private(returning_visitor: bool) -> None: assert client.get("/me").status_code == 401 +@pytest.mark.parametrize( + "name", + [ + "session_api", + "session_login", + *( + pytest.param( + name, + marks=pytest.mark.skipif( + importlib.util.find_spec("jwt") is None, + reason=f"{name} needs the jwt extra (PyJWT)", + ), + ) + for name in ("session_jwt", "session_jwt_claims") + ), + ], +) +def test_session_example_warns_without_a_secret_key( + monkeypatch: pytest.MonkeyPatch, name: str +) -> None: + """No placeholder key: an unset SESSION_SECRET_KEY warns and a random one is used.""" + monkeypatch.delenv("SESSION_SECRET_KEY") + with pytest.warns(UserWarning, match="SESSION_SECRET_KEY is not set") as record: + first = load_example(name) + assert record[0].filename == str(EXAMPLES_DIR / f"{name}.py") + with pytest.warns(UserWarning, match="SESSION_SECRET_KEY is not set"): + second = load_example(name) + assert len(first.config.secret_key.get_secret_value()) >= 32 + assert ( + first.config.secret_key.get_secret_value() + != second.config.secret_key.get_secret_value() + ) + + @pytest.mark.skipif( importlib.util.find_spec("jwt") is None, reason="session_jwt needs the jwt extra (PyJWT)",