"""E22.5 S1 — what a log line may NOT carry. `logs.redact_text` is a safety net over addresses and credential params, and it was doing the whole job: every one of these call sites was putting user content into a line and relying on the net to catch it. Two things were wrong with that. What the net does NOT cover went in whole — a search term, a source URL, an upstream body that quotes the request back — and what it DOES cover came out as `[email]`, so the line's one variable was a constant and the line said nothing. The cases below pin the call sites. The last one pins the family: no future log call may pass the same kind of value without saying, on the line, why it is safe. """ import ast import json import logging import pathlib import httpx import pytest from app import logs from app.youtube.client import YouTubeError, _upstream_failure TERM = "the exact thing they typed" class _Sink(logging.Handler): """The stdout handler's own filter and formatter, so a case sees the bytes a deployment writes — not the record before redaction ran.""" def __init__(self): super().__init__(level=logging.NOTSET) self.lines: list[str] = [] self.setFormatter(logs.JsonFormatter()) self.addFilter(logs.ContextFilter()) def emit(self, record): self.lines.append(self.format(record)) def objects(self) -> list[dict]: return [json.loads(line) for line in self.lines] def at(self, level: str) -> list[dict]: return [o for o in self.objects() if o["level"] == level] @pytest.fixture def sink(): logger = logging.getLogger("siftlode") handler = _Sink() previous = logger.level logger.addHandler(handler) logger.setLevel(logs.TRACE) try: yield handler finally: logger.removeHandler(handler) logger.setLevel(previous) def _youtube_403(term: str) -> httpx.Response: """A real-shaped Data API error. `message` is prose, and prose from an upstream quotes the request back — which is how a search term ends up in our log through someone else's words.""" return httpx.Response( 403, json={ "error": { "code": 403, "message": f"The request for '{term}' cannot be completed: quota exceeded.", "errors": [{"reason": "quotaExceeded", "domain": "youtube.quota"}], } }, request=httpx.Request("GET", "https://www.googleapis.com/youtube/v3/search"), ) def test_an_upstream_failure_logs_the_reason_code_and_not_the_body(sink): err = _upstream_failure("GET", "search", _youtube_403(TERM)) (line,) = sink.at("WARNING") assert line["attrs"]["reason"] == "quotaExceeded" assert line["attrs"]["status"] == 403 assert TERM not in json.dumps(line) # The error is raised to callers that put it in a log line of their own (routes/channels.py) # and in a 422 answered to the user, so the body must not travel in it either. assert TERM not in str(err) assert err.reason == "quotaExceeded" def test_the_body_is_kept_for_the_one_user_who_consented_to_it(sink): _upstream_failure("GET", "search", _youtube_403(TERM)) # TRACE is emitted only while the user has turned Teljes on for their own session, which is # exactly the consent the privacy policy describes. The diagnostic is not lost, it is gated. (trace,) = sink.at("TRACE") assert TERM in trace["attrs"]["body"] def test_an_unparseable_body_still_names_the_status(sink): """A proxy answering HTML has no `reason` to take — the line must still be worth reading, and must still not fall back to printing the body.""" resp = httpx.Response( 502, text="
Bad Gateway", request=httpx.Request("GET", "https://www.googleapis.com/youtube/v3/search"), ) err = _upstream_failure("GET", "search", resp) (line,) = sink.at("WARNING") assert line["attrs"] == { "method": "GET", "path": "search", "status": 502, "reason": "