|
4 | 4 | uncached instead of turning every cached route into a 500. |
5 | 5 | """ |
6 | 6 |
|
| 7 | +import hashlib |
7 | 8 | import logging |
8 | 9 |
|
9 | 10 | import pytest |
10 | 11 | from fastapi import FastAPI |
11 | 12 | from fastapi.responses import PlainTextResponse |
12 | 13 | from fastapi.testclient import TestClient |
| 14 | +from starlette.types import Message |
| 15 | +from starlette.types import Scope |
13 | 16 |
|
14 | 17 | from fastapi_cachex import BackendProxy |
15 | 18 | from fastapi_cachex import cache |
@@ -131,3 +134,103 @@ async def large() -> PlainTextResponse: |
131 | 134 |
|
132 | 135 | assert response.status_code == 200 |
133 | 136 | assert response.text == body |
| 137 | + |
| 138 | + |
| 139 | +@pytest.mark.parametrize( |
| 140 | + ("fail_get", "fail_set", "message"), |
| 141 | + [(True, False, "read failed"), (False, True, "write failed")], |
| 142 | + ids=["get", "set"], |
| 143 | +) |
| 144 | +def test_failure_warning_does_not_log_the_cache_key( |
| 145 | + caplog: pytest.LogCaptureFixture, *, fail_get: bool, fail_set: bool, message: str |
| 146 | +) -> None: |
| 147 | + """The key holds the query string and vary values: only a digest at WARNING (#299).""" |
| 148 | + BackendProxy.set(FailingBackend(fail_get=fail_get, fail_set=fail_set)) |
| 149 | + app = FastAPI() |
| 150 | + |
| 151 | + @app.get("/callback") |
| 152 | + @cache(ttl=60, vary=["X-Tenant"]) |
| 153 | + async def callback() -> dict[str, str]: |
| 154 | + return {"ok": "yes"} |
| 155 | + |
| 156 | + with caplog.at_level(logging.DEBUG, logger="fastapi_cachex.cache"): |
| 157 | + response = TestClient(app).get( |
| 158 | + "/callback", |
| 159 | + params={"token": "s3cr3t-token", "email": "alice@example.com"}, |
| 160 | + headers={"X-Tenant": "tenant-secret"}, |
| 161 | + ) |
| 162 | + |
| 163 | + assert response.status_code == 200 |
| 164 | + [warning] = [ |
| 165 | + r.getMessage() |
| 166 | + for r in caplog.records |
| 167 | + if r.levelno == logging.WARNING and message in r.getMessage() |
| 168 | + ] |
| 169 | + for secret in ("s3cr3t-token", "alice", "tenant-secret", "|||"): |
| 170 | + assert secret not in warning |
| 171 | + assert "method=GET" in warning |
| 172 | + assert "path='/callback'" in warning |
| 173 | + assert "error=ConnectionError('backend unreachable')" in warning |
| 174 | + |
| 175 | + # The full key is at DEBUG only, tagged with the same digest. |
| 176 | + [debug] = [ |
| 177 | + r.getMessage() |
| 178 | + for r in caplog.records |
| 179 | + if r.levelno == logging.DEBUG and message in r.getMessage() |
| 180 | + ] |
| 181 | + key = debug.split(" key=", 1)[1] |
| 182 | + assert "token=s3cr3t-token" in key |
| 183 | + assert "tenant-secret" in key |
| 184 | + digest = hashlib.sha256(key.encode()).hexdigest()[:12] |
| 185 | + assert f"key_ref={digest}" in warning |
| 186 | + assert f"key_ref={digest}" in debug |
| 187 | + |
| 188 | + |
| 189 | +async def test_failure_warning_escapes_control_characters_in_the_path( |
| 190 | + caplog: pytest.LogCaptureFixture, |
| 191 | +) -> None: |
| 192 | + """Control characters in the decoded path are escaped, not written raw.""" |
| 193 | + BackendProxy.set(FailingBackend(fail_get=True, fail_set=False)) |
| 194 | + app = FastAPI() |
| 195 | + |
| 196 | + @app.get("/{name}") |
| 197 | + @cache(ttl=60) |
| 198 | + async def item(name: str) -> dict[str, str]: |
| 199 | + return {"name": name} |
| 200 | + |
| 201 | + # ASGI servers percent-decode the path, so ``%0D%0A`` arrives as a real |
| 202 | + # CR/LF; test clients normalise it away, so drive the app directly. |
| 203 | + # ``request.url`` already drops CR/LF/tab (urllib), but an ANSI escape or |
| 204 | + # a line separator gets through and must show up escaped. |
| 205 | + scope: Scope = { |
| 206 | + "type": "http", |
| 207 | + "asgi": {"version": "3.0"}, |
| 208 | + "http_version": "1.1", |
| 209 | + "method": "GET", |
| 210 | + "scheme": "http", |
| 211 | + "path": "/a\r\n\x1b[2K\u2028FAKE entry", |
| 212 | + "raw_path": b"/a%0D%0A%1B%5B2K%E2%80%A8FAKE%20entry", |
| 213 | + "root_path": "", |
| 214 | + "query_string": b"", |
| 215 | + "headers": [(b"host", b"testserver")], |
| 216 | + "client": ("127.0.0.1", 1), |
| 217 | + "server": ("testserver", 80), |
| 218 | + } |
| 219 | + sent: list[Message] = [] |
| 220 | + |
| 221 | + async def receive() -> Message: |
| 222 | + return {"type": "http.request", "body": b"", "more_body": False} |
| 223 | + |
| 224 | + async def send(message: Message) -> None: |
| 225 | + sent.append(message) |
| 226 | + |
| 227 | + with caplog.at_level(logging.WARNING, logger="fastapi_cachex.cache"): |
| 228 | + await app(scope, receive, send) |
| 229 | + |
| 230 | + assert sent[0]["status"] == 200 |
| 231 | + [warning] = [ |
| 232 | + r.getMessage() for r in caplog.records if "read failed" in r.getMessage() |
| 233 | + ] |
| 234 | + for raw in ("\n", "\r", "\x1b", "\u2028"): |
| 235 | + assert raw not in warning |
| 236 | + assert "\\x1b[2K\\u2028FAKE entry'" in warning |
0 commit comments