"""Unit tests for :class:`AuthRefreshMiddleware` (Tier-12 PR 12.8). Pins the contract documented in ``src/notebooklm/_middleware/auth_refresh.py`` and ADR-0009 §"Chain ordering": - **Pass-through on success.** Single ``next_call``; result returned. - **Pass-through on non-auth exception.** ``TransportRateLimited`` / ``TransportServerError`` propagate to ``RetryMiddleware`` outside. - **Pass-through when no refresh callback configured.** The ``refresh_callback_enabled`` gate matches the legacy ``host._refresh_callback is not None`` check. - **Pass-through when ``disable_internal_retries`` is set.** A non-idempotent / probe-then-create method (effective disable bool set on the context) is NOT replayed after an auth error — the write may have already committed before the 401/403 surfaced (issue #1157). - **Refresh-and-retry on auth error.** First ``next_call`` raises ``httpx.HTTPStatusError`` recognized by ``is_auth_error`` → refresh callable runs (coalesced single-flight) → optional post-refresh sleep → metric increment → rebuilt request envelope when a snapshot provider is wired → exactly one retry via ``next_call(retry_request)``. - **Refresh failure** → wrap original ``HTTPStatusError`` in ``TransportAuthExpired`` and propagate. - **Exactly one retry.** Per ADR-0009 §"Retry semantics", a second auth error on the retry leg propagates — no second refresh, no recursion. - **Post-refresh sleep honored** when ``refresh_retry_delay > 0``. - **Live-bound `refresh_retry_delay`.** Callable getter so test mutation on the host still takes effect (matches the RetryMiddleware idiom). - **Log shape preservation** — "auth error detected, attempting token refresh" / "Token refresh failed: X" / "Token refresh successful, retrying Y" match the legacy transport messages bit-for-bit so log-grep alerts keep matching. - **Metrics increment** — ``rpc_auth_retries`` incremented exactly once per successful refresh. - **Log-label fallback** — missing ``log_label`` in context surfaces ``""`` rather than ``KeyError`` (defensive for ``__new__``-built fixtures; matches Drain/Retry behavior). """ from __future__ import annotations from collections.abc import Awaitable, Callable from typing import Any import httpx import pytest from notebooklm._client_metrics import ClientMetrics from notebooklm._middleware.auth_refresh import AuthRefreshMiddleware from notebooklm._middleware.context import RPC_CONTEXT_RETRY_DEADLINE from notebooklm._middleware.core import NextCall, RpcRequest, RpcResponse, build_chain from notebooklm._request_types import AuthSnapshot from notebooklm._runtime.helpers import is_auth_error from notebooklm._transport_errors import ( TransportAuthExpired, TransportRateLimited, TransportServerError, ) # The ``tests/`` package chain is complete; ``tests._fixtures.chain`` is the # fully-qualified import path documented in ``tests/_fixtures/__init__.py``. from tests._fixtures.chain import make_request def _recording_sleep() -> tuple[Callable[[float], Awaitable[None]], list[float]]: slept: list[float] = [] async def sleep(seconds: float) -> None: slept.append(seconds) return sleep, slept def _auth_error(*, status: int = 401, url: str = "https://example.test/x") -> httpx.HTTPStatusError: """Build an ``httpx.HTTPStatusError`` that ``is_auth_error`` recognizes.""" request = httpx.Request("POST", url) response = httpx.Response(status, request=request) return httpx.HTTPStatusError(f"HTTP {status}", request=request, response=response) def _scripted_terminal(behaviors: list[Any]) -> tuple[NextCall, list[RpcRequest]]: """Yield each ``behaviors`` entry per call. Exceptions raise; Responses wrap.""" calls: list[RpcRequest] = [] iterator = iter(behaviors) async def terminal(request: RpcRequest) -> RpcResponse: calls.append(request) nxt = next(iterator) if isinstance(nxt, BaseException): raise nxt return RpcResponse(response=nxt, context=request.context) return terminal, calls def _make_middleware( *, refresh_callable: Callable[[], Awaitable[None]] | None = None, refresh_enabled: bool = True, refresh_retry_delay: float = 0.0, sleep: Callable[[float], Awaitable[object]] | None = None, metrics: ClientMetrics | None = None, snapshot_provider: Callable[[], Awaitable[AuthSnapshot]] | None = None, auth_error_predicate: Callable[[Exception], bool] = is_auth_error, ) -> AuthRefreshMiddleware: """Build an ``AuthRefreshMiddleware`` with sensible defaults for tests.""" async def _noop_refresh() -> None: return None return AuthRefreshMiddleware( refresh_callable=refresh_callable or _noop_refresh, is_auth_error=auth_error_predicate, refresh_callback_enabled=lambda: refresh_enabled, refresh_retry_delay=lambda: refresh_retry_delay, snapshot_provider=snapshot_provider, sleep=sleep, metrics=metrics, ) # --------------------------------------------------------------------------- # Pass-through paths # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_passes_through_on_success() -> None: """No exception → single ``next_call`` invocation, response returned.""" terminal, calls = _scripted_terminal([httpx.Response(200, content=b"ok")]) middleware = _make_middleware() chain = build_chain([middleware], terminal) response = await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"})) assert len(calls) == 1 assert response.response.status_code == 200 @pytest.mark.asyncio async def test_passes_through_on_rate_limited() -> None: """``TransportRateLimited`` propagates to ``RetryMiddleware`` outside.""" request = httpx.Request("POST", "https://example.test/x") boom = TransportRateLimited( "rate limited", retry_after=1, response=httpx.Response(429, request=request), original=httpx.HTTPStatusError("HTTP 429", request=request, response=httpx.Response(429)), ) terminal, calls = _scripted_terminal([boom]) middleware = _make_middleware() chain = build_chain([middleware], terminal) with pytest.raises(TransportRateLimited) as excinfo: await chain(make_request()) assert excinfo.value is boom assert len(calls) == 1 @pytest.mark.asyncio async def test_passes_through_on_server_error() -> None: """``TransportServerError`` propagates.""" request = httpx.Request("POST", "https://example.test/x") boom = TransportServerError( "server error", original=httpx.HTTPStatusError("HTTP 503", request=request, response=httpx.Response(503)), ) terminal, calls = _scripted_terminal([boom]) middleware = _make_middleware() chain = build_chain([middleware], terminal) with pytest.raises(TransportServerError) as excinfo: await chain(make_request()) assert excinfo.value is boom assert len(calls) == 1 @pytest.mark.asyncio async def test_passes_through_when_refresh_callback_not_configured() -> None: """No refresh callback → propagate auth error without refresh attempt. Matches the legacy ``host._refresh_callback is not None`` gate in the leaf — a client constructed without ``refresh_callback`` should not silently swallow auth errors. """ boom = _auth_error(status=401) terminal, calls = _scripted_terminal([boom]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh, refresh_enabled=False) chain = build_chain([middleware], terminal) with pytest.raises(httpx.HTTPStatusError) as excinfo: await chain(make_request()) assert excinfo.value is boom assert len(calls) == 1 assert refresh_calls == [] # refresh NOT triggered @pytest.mark.asyncio async def test_passes_through_on_non_auth_http_error() -> None: """A non-auth HTTPStatusError (e.g. 404) propagates without refresh.""" boom = _auth_error(status=404) # 404 is NOT 400/401/403 terminal, calls = _scripted_terminal([boom]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) with pytest.raises(httpx.HTTPStatusError) as excinfo: await chain(make_request()) assert excinfo.value is boom assert refresh_calls == [] @pytest.mark.asyncio async def test_passes_through_when_disable_internal_retries_set() -> None: """``disable_internal_retries`` on the context suppresses the auth replay. Regression for issue #1157: a non-idempotent / probe-then-create method arrives with the post-resolution effective disable bool set on its context. A 401/403 can land *after* the server committed the write, so re-POSTing would duplicate the resource / invite / generation. The middleware must propagate the original auth error instead of refreshing and retrying. """ boom = _auth_error(status=401) terminal, calls = _scripted_terminal([boom]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) with pytest.raises(httpx.HTTPStatusError) as excinfo: await chain( make_request( context={ "log_label": "RPC CREATE_NOTEBOOK", "disable_internal_retries": True, } ) ) assert excinfo.value is boom assert len(calls) == 1 # initial attempt only — no replay assert refresh_calls == [] # refresh NOT triggered @pytest.mark.asyncio async def test_refreshes_when_disable_internal_retries_falsy() -> None: """An explicit falsy ``disable_internal_retries`` still allows the replay. Guards the gate against over-triggering: a retry-safe method whose effective disable flag is False must keep the refresh-and-retry path. """ boom = _auth_error(status=401) terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"retry-ok")]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) response = await chain( make_request( context={ "log_label": "RPC LIST_NOTEBOOKS", "disable_internal_retries": False, } ) ) assert refresh_calls == [None] assert len(calls) == 2 assert response.response.status_code == 200 # --------------------------------------------------------------------------- # Refresh-and-retry path # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refreshes_and_retries_on_auth_error() -> None: """401 → refresh → retry → success.""" boom = _auth_error(status=401) terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"retry-ok")]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) response = await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"})) assert refresh_calls == [None] assert len(calls) == 2 # initial + retry assert response.response.status_code == 200 assert response.response.content == b"retry-ok" @pytest.mark.asyncio async def test_refresh_rebuilds_request_envelope_from_fresh_snapshot() -> None: """Post-refresh retry replaces URL, headers, and body before terminal send.""" boom = _auth_error(status=401) terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"retry-ok")]) fresh_snapshot = AuthSnapshot( csrf_token="CSRF_NEW", session_id="SID_NEW", authuser=1, account_email=None, ) build_snapshots: list[AuthSnapshot] = [] async def snapshot_provider() -> AuthSnapshot: return fresh_snapshot def build_request(snapshot: AuthSnapshot) -> tuple[str, str, dict[str, str]]: build_snapshots.append(snapshot) return ( f"https://example.test/x?sid={snapshot.session_id}", f"body-{snapshot.csrf_token}", {"X-Goog-AuthUser": str(snapshot.authuser)}, ) middleware = _make_middleware(snapshot_provider=snapshot_provider) chain = build_chain([middleware], terminal) request = make_request( url="https://example.test/x?sid=SID_OLD", headers={"X-Goog-AuthUser": "0"}, body=b"body-CSRF_OLD", context={"log_label": "RPC LIST_NOTEBOOKS", "build_request": build_request}, ) response = await chain(request) assert response.response.status_code == 200 assert len(calls) == 2 assert calls[0] is request retry_request = calls[1] assert retry_request is not request assert retry_request.url == "https://example.test/x?sid=SID_NEW" assert retry_request.headers == {"X-Goog-AuthUser": "1"} assert retry_request.body == b"body-CSRF_NEW" assert retry_request.context is request.context assert request.context["auth_snapshot"] == fresh_snapshot assert build_snapshots == [fresh_snapshot] @pytest.mark.parametrize("status", [400, 401, 403]) @pytest.mark.asyncio async def test_refreshes_on_all_auth_status_codes(status: int) -> None: """Google returns 400 / 401 / 403 for auth errors — all trigger refresh.""" boom = _auth_error(status=status) terminal, calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) response = await chain(make_request()) assert refresh_calls == [None] assert len(calls) == 2 assert response.response.status_code == 200 @pytest.mark.asyncio async def test_refresh_retry_delay_honored() -> None: """``refresh_retry_delay > 0`` → sleep that duration before retry.""" boom = _auth_error() terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) sleep, slept = _recording_sleep() middleware = _make_middleware(refresh_retry_delay=1.5, sleep=sleep) chain = build_chain([middleware], terminal) await chain(make_request()) assert slept == [1.5] @pytest.mark.asyncio async def test_refresh_retry_delay_zero_skips_sleep() -> None: """``refresh_retry_delay == 0`` → no sleep call.""" boom = _auth_error() terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) sleep, slept = _recording_sleep() middleware = _make_middleware(refresh_retry_delay=0.0, sleep=sleep) chain = build_chain([middleware], terminal) await chain(make_request()) assert slept == [] @pytest.mark.asyncio async def test_refresh_retry_delay_clamped_by_inherited_deadline() -> None: """Issue #1873: the wire-401 post-refresh sleep is clamped to the aggregate. When the RPC executor seeds ``RPC_CONTEXT_RETRY_DEADLINE``, the auth-refresh layer must clamp its post-refresh sleep to the time remaining since T0 — otherwise a ``refresh_retry_delay`` larger than the remaining budget would re-POST past the logical call's deadline (mirroring the decode-time path). """ from notebooklm._deadline import RuntimeDeadline clock = [1000.0] def monotonic() -> float: return clock[0] # 10s aggregate budget; advance the clock so only 2s remain when the # post-refresh sleep is computed. deadline = RuntimeDeadline.start(10.0, monotonic=monotonic) clock[0] = 1008.0 boom = _auth_error() terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) sleep, slept = _recording_sleep() # refresh_retry_delay (100s) far exceeds the 2s remaining budget. middleware = _make_middleware(refresh_retry_delay=100.0, sleep=sleep) chain = build_chain([middleware], terminal) await chain(make_request(context={RPC_CONTEXT_RETRY_DEADLINE: deadline})) # Clamped to the remaining 2s, not the full 100s. assert slept == [2.0] # --------------------------------------------------------------------------- # Refresh failure → TransportAuthExpired # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_failure_raises_transport_auth_expired() -> None: """Refresh callable raises → wrap in ``TransportAuthExpired``.""" boom = _auth_error(status=401) terminal, calls = _scripted_terminal([boom]) # only one attempt — refresh fails before retry refresh_error = RuntimeError("refresh blew up") async def refresh() -> None: raise refresh_error middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) with pytest.raises(TransportAuthExpired) as excinfo: await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"})) assert excinfo.value.original is boom assert excinfo.value.__cause__ is refresh_error assert "RPC LIST_NOTEBOOKS" in str(excinfo.value) assert len(calls) == 1 # NO retry attempted # --------------------------------------------------------------------------- # Once-per-logical-call contract (codex iter-1 catch on PR 12.8) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_auth_refresh_skipped_when_context_marks_already_refreshed() -> None: """If ``context["auth_refreshed"]`` is set, a fresh 401 propagates. Models the load-bearing scenario where ``RetryMiddleware`` (outside this middleware in the final chain) re-invokes the chain after a 429 that fired post-refresh. The retry hits a 401 again; without the per-request flag, AuthRefreshMiddleware would refresh a SECOND time. With it, the 401 propagates — restoring the pre-PR-12.7 "one refresh max per logical call" contract. Codex iter-1 PR 12.8 finding. """ boom = _auth_error() terminal, _calls = _scripted_terminal([boom]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) # Simulate a chain re-entry where a prior leg already refreshed. request = make_request(context={"auth_refreshed": True}) with pytest.raises(httpx.HTTPStatusError) as excinfo: await chain(request) assert excinfo.value is boom assert refresh_calls == [] # NO refresh — prior leg already did it @pytest.mark.asyncio async def test_context_auth_refreshed_flag_set_after_first_refresh() -> None: """A successful refresh marks ``context["auth_refreshed"]`` so a later chain re-entry through ``RetryMiddleware`` won't refresh again. """ boom = _auth_error() terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) async def refresh() -> None: return None middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) request = make_request(context={"log_label": "RPC LIST_NOTEBOOKS"}) await chain(request) assert request.context["auth_refreshed"] is True # --------------------------------------------------------------------------- # Exactly-one-retry contract # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_second_auth_error_on_retry_propagates_no_recursion() -> None: """Per ADR-0009: "exactly one retry per next_call invocation". First call raises 401 → refresh → retry raises 401 again → propagate. The middleware must NOT refresh a second time, NOT recurse. """ first_boom = _auth_error(status=401) second_boom = _auth_error(status=401) terminal, calls = _scripted_terminal([first_boom, second_boom]) refresh_calls: list[None] = [] async def refresh() -> None: refresh_calls.append(None) middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) with pytest.raises(httpx.HTTPStatusError) as excinfo: await chain(make_request()) assert excinfo.value is second_boom assert len(refresh_calls) == 1 # exactly one refresh assert len(calls) == 2 # initial + one retry # --------------------------------------------------------------------------- # Metrics emission # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_metrics_increment_on_successful_refresh() -> None: """``rpc_auth_retries`` increments once per successful refresh.""" boom = _auth_error() terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) metrics = ClientMetrics() async def refresh() -> None: return None middleware = _make_middleware(refresh_callable=refresh, metrics=metrics) chain = build_chain([middleware], terminal) await chain(make_request()) assert metrics.snapshot().rpc_auth_retries == 1 @pytest.mark.asyncio async def test_metrics_not_incremented_on_refresh_failure() -> None: """Refresh failure → no auth-retry metric (no retry happened).""" boom = _auth_error() terminal, _calls = _scripted_terminal([boom]) metrics = ClientMetrics() async def refresh() -> None: raise RuntimeError("refresh failed") middleware = _make_middleware(refresh_callable=refresh, metrics=metrics) chain = build_chain([middleware], terminal) with pytest.raises(TransportAuthExpired): await chain(make_request()) assert metrics.snapshot().rpc_auth_retries == 0 # --------------------------------------------------------------------------- # Log shape preservation # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_log_shape_on_successful_refresh(caplog: pytest.LogCaptureFixture) -> None: """Log messages match the legacy transport shape verbatim.""" boom = _auth_error() terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) async def refresh() -> None: return None middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) with caplog.at_level("INFO", logger="notebooklm._core"): await chain(make_request(context={"log_label": "RPC LIST_NOTEBOOKS"})) info_msgs = [r.message for r in caplog.records if r.levelname == "INFO"] assert any( "RPC LIST_NOTEBOOKS auth error detected, attempting token refresh" in m for m in info_msgs ) assert any("Token refresh successful, retrying RPC LIST_NOTEBOOKS" in m for m in info_msgs) @pytest.mark.asyncio async def test_log_shape_on_refresh_failure(caplog: pytest.LogCaptureFixture) -> None: """Refresh failure log matches "Token refresh failed: X".""" boom = _auth_error() terminal, _calls = _scripted_terminal([boom]) async def refresh() -> None: raise RuntimeError("login expired") middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) with ( caplog.at_level("WARNING", logger="notebooklm._core"), pytest.raises(TransportAuthExpired), ): await chain(make_request()) warn_msgs = [r.message for r in caplog.records if r.levelname == "WARNING"] assert any("Token refresh failed: login expired" in m for m in warn_msgs) # --------------------------------------------------------------------------- # Log-label fallback # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_missing_log_label_falls_back_to_sentinel( caplog: pytest.LogCaptureFixture, ) -> None: """A request without ``log_label`` does not raise ``KeyError``.""" boom = _auth_error() terminal, _calls = _scripted_terminal([boom, httpx.Response(200, content=b"ok")]) async def refresh() -> None: return None middleware = _make_middleware(refresh_callable=refresh) chain = build_chain([middleware], terminal) with caplog.at_level("INFO", logger="notebooklm._core"): await chain(make_request(context={})) # no log_label info_msgs = [r.message for r in caplog.records if r.levelname == "INFO"] assert any("" in m for m in info_msgs) # --------------------------------------------------------------------------- # Live-bound refresh_retry_delay # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_refresh_retry_delay_is_live_bound() -> None: """Mutating the delay between chain calls takes effect on the next call.""" delay = [0.0] async def refresh() -> None: return None sleep, slept = _recording_sleep() middleware = AuthRefreshMiddleware( refresh_callable=refresh, is_auth_error=is_auth_error, refresh_callback_enabled=lambda: True, refresh_retry_delay=lambda: delay[0], sleep=sleep, ) boom1 = _auth_error() boom2 = _auth_error() terminal, _calls = _scripted_terminal( [boom1, httpx.Response(200, content=b"ok"), boom2, httpx.Response(200, content=b"ok")] ) chain = build_chain([middleware], terminal) # Call 1: delay=0 → no sleep. await chain(make_request()) assert slept == [] # Mutate the delay. delay[0] = 0.5 # Call 2: delay=0.5 → one sleep of 0.5. await chain(make_request()) assert slept == [0.5] # --------------------------------------------------------------------------- # Type hygiene # --------------------------------------------------------------------------- def test_middleware_satisfies_protocol() -> None: """``AuthRefreshMiddleware`` instance is assignable to ``Middleware``.""" from notebooklm._middleware.core import Middleware async def _noop() -> None: return None middleware: Middleware = AuthRefreshMiddleware( refresh_callable=_noop, is_auth_error=is_auth_error, refresh_callback_enabled=lambda: True, refresh_retry_delay=lambda: 0.0, ) assert callable(middleware)