Files
wehub-resource-sync 09e9f3545f
Test / Code Quality (push) Has been cancelled
Test / Test (macos-latest, Python 3.10) (push) Has been cancelled
Test / Test (macos-latest, Python 3.11) (push) Has been cancelled
Test / Test (macos-latest, Python 3.12) (push) Has been cancelled
Test / Test (macos-latest, Python 3.13) (push) Has been cancelled
Test / Test (macos-latest, Python 3.14) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.10) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.11) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.12) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.13) (push) Has been cancelled
Test / Test (ubuntu-latest, Python 3.14) (push) Has been cancelled
Test / Test (windows-latest, Python 3.10) (push) Has been cancelled
Test / Test (windows-latest, Python 3.11) (push) Has been cancelled
Test / Test (windows-latest, Python 3.12) (push) Has been cancelled
Test / Test (windows-latest, Python 3.13) (push) Has been cancelled
Test / Test (windows-latest, Python 3.14) (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
dependency-audit / pip-audit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

474 lines
18 KiB
Python

"""Integration tests for automatic token refresh."""
import asyncio
from unittest.mock import MagicMock
import httpx
import pytest
from notebooklm import NotebookLMClient
from notebooklm.auth import AuthTokens
from notebooklm.rpc import RPCError, RPCMethod
from tests.integration.conftest import install_post_as_stream
# mock-based refresh-callback wiring tests; no HTTP, no cassette.
# Opt out of the tier-enforcement hook in tests/integration/conftest.py.
pytestmark = pytest.mark.allow_no_vcr
class TestAutoRefreshIntegration:
@pytest.mark.asyncio
async def test_client_has_refresh_callback_wired(self):
"""NotebookLMClient should wire refresh_auth as callback."""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
# Bound methods aren't identical, so compare underlying function
assert client._collaborators.auth_coord._refresh_callback is not None
assert (
client._collaborators.auth_coord._refresh_callback.__func__
is NotebookLMClient.refresh_auth
)
# ``_refresh_lock`` is lazily created on first ``_await_refresh``.
# At construction time it is ``None`` so the client can be
# instantiated outside a running loop; the helper allocates the
# lock on demand inside the async refresh path.
assert client._collaborators.auth_coord._refresh_lock is None
@pytest.mark.asyncio
async def test_full_refresh_flow_http_error(self):
"""Test complete auto-refresh flow for HTTP 401 errors."""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="old_csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
# Override retry delay for faster tests
client._composed.chain_host._refresh_retry_delay = 0
# Track refresh calls
refresh_calls = []
async def tracking_refresh():
refresh_calls.append(True)
# Simulate successful refresh
client._auth.csrf_token = "new_csrf"
# Wave 3 of plan ``host-protocol-removal`` deleted the
# Session-level ``update_auth_headers`` forward; call the
# canonical coordinator method directly with explicit kwargs.
client._collaborators.auth_coord.update_auth_headers(
auth=client._auth,
kernel=client._collaborators.kernel,
)
return client._auth
client._collaborators.auth_coord._refresh_callback = tracking_refresh
# Mock HTTP responses
call_count = [0]
async def mock_post(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
# First call: simulate HTTP 401
request = httpx.Request("POST", args[0])
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("Unauthorized", request=request, response=response)
# Second call: success
response = MagicMock()
response.text = ')]}\'\\n[["wrb.fr","wXbhsf",[[[["nb1"],["Notebook 1"]]]]]]'
response.raise_for_status = MagicMock()
return response
# Override the runtime decode-response seam before the RPC fires.
client._seams.decode_response = lambda *a, **kw: [[["nb1"], ["Notebook 1"]]]
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
await client.notebooks.list()
assert len(refresh_calls) == 1, "Should have refreshed once"
assert call_count[0] == 2, "Should have retried once"
@pytest.mark.asyncio
async def test_full_refresh_flow_rpc_error(self):
"""Test complete auto-refresh flow for RPC auth errors."""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="old_csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
client._composed.chain_host._refresh_retry_delay = 0
refresh_calls = []
async def tracking_refresh():
refresh_calls.append(True)
client._auth.csrf_token = "new_csrf"
# Wave 3 of plan ``host-protocol-removal`` deleted the
# Session-level ``update_auth_headers`` forward; call the
# canonical coordinator method directly with explicit kwargs.
client._collaborators.auth_coord.update_auth_headers(
auth=client._auth,
kernel=client._collaborators.kernel,
)
return client._auth
client._collaborators.auth_coord._refresh_callback = tracking_refresh
# Mock HTTP to succeed, but decode_response to fail with auth error first
async def mock_post(*args, **kwargs):
response = MagicMock()
response.text = "mock response"
response.raise_for_status = MagicMock()
return response
decode_count = [0]
def mock_decode(*args, **kwargs):
decode_count[0] += 1
if decode_count[0] == 1:
raise RPCError("Authentication expired")
return [[["nb1"], ["Notebook 1"]]]
# Override the runtime decode-response seam before the RPC fires.
client._seams.decode_response = mock_decode
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
await client.notebooks.list()
assert len(refresh_calls) == 1, "Should have refreshed once"
assert decode_count[0] == 2, "Should have retried once"
@pytest.mark.asyncio
async def test_wire_401_then_decoded_auth_error_refreshes_once(self):
"""Issue #1205: a wire-401 followed by a decoded auth error on the SAME
logical call must drive exactly ONE refresh.
Before consolidation the HTTP-status layer (``AuthRefreshMiddleware``)
and the decoded-RPC layer (``RpcExecutor``) tracked their once-per-call
guard independently — the chain's per-request ``auth_refreshed`` flag
and the executor's ``_is_retry`` flag could not see each other. So a
``401 → refresh#1 → 200 → decoded-auth-error → refresh#2`` sequence
refreshed twice. The shared :class:`RefreshBudget` threaded through both
layers now bounds the logical call to a single refresh; the decoded
auth error surfaces to the caller instead of triggering a second
refresh.
"""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="old_csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
client._composed.chain_host._refresh_retry_delay = 0
refresh_calls = []
async def tracking_refresh():
refresh_calls.append(True)
client._auth.csrf_token = "new_csrf"
client._collaborators.auth_coord.update_auth_headers(
auth=client._auth,
kernel=client._collaborators.kernel,
)
return client._auth
client._collaborators.auth_coord._refresh_callback = tracking_refresh
call_count = [0]
async def mock_post(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
# Wire 401 → HTTP-status layer refreshes (refresh #1) and
# retries the POST.
request = httpx.Request("POST", args[0])
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("Unauthorized", request=request, response=response)
# The post-refresh retry returns HTTP 200; the decoded payload
# still carries an auth error.
response = MagicMock()
response.text = "mock response"
response.raise_for_status = MagicMock()
return response
auth_rpc_error = RPCError("authentication expired")
def mock_decode(*args, **kwargs):
raise auth_rpc_error
client._seams.decode_response = mock_decode
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
# The decoded auth error surfaces — the shared budget was already
# spent by the HTTP-status refresh, so the decoded layer does NOT
# refresh again and re-raises the original auth error.
with pytest.raises(RPCError) as raised:
await client.notebooks.list()
assert raised.value is auth_rpc_error
assert len(refresh_calls) == 1, "wire-401 + decoded-auth-error must refresh exactly once"
# Two POSTs: the initial 401 and the single post-refresh retry. No
# third POST, because the decoded layer did not refresh-and-retry.
assert call_count[0] == 2
@pytest.mark.asyncio
async def test_decoded_auth_retry_increments_auth_retry_metric(self):
"""Issue #1205: the decoded-RPC refresh layer now counts the auth retry.
Before consolidation only the HTTP-status layer incremented
``rpc_auth_retries``; the decode-time refresh-and-retry leg silently
skipped it. The shared refresh body counts on both layers.
"""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="old_csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
client._composed.chain_host._refresh_retry_delay = 0
async def tracking_refresh():
client._auth.csrf_token = "new_csrf"
client._collaborators.auth_coord.update_auth_headers(
auth=client._auth,
kernel=client._collaborators.kernel,
)
return client._auth
client._collaborators.auth_coord._refresh_callback = tracking_refresh
async def mock_post(*args, **kwargs):
response = MagicMock()
response.text = "mock response"
response.raise_for_status = MagicMock()
return response
decode_count = [0]
def mock_decode(*args, **kwargs):
decode_count[0] += 1
if decode_count[0] == 1:
raise RPCError("Authentication expired")
return [[["nb1"], ["Notebook 1"]]]
client._seams.decode_response = mock_decode
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
await client.notebooks.list()
assert client._collaborators.metrics.snapshot().rpc_auth_retries == 1
@pytest.mark.asyncio
async def test_refresh_delay_is_applied(self):
"""Test that retry delay is actually applied."""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
client._composed.chain_host._refresh_retry_delay = 0.1 # 100ms delay
async def mock_refresh():
return auth
client._collaborators.auth_coord._refresh_callback = mock_refresh
call_count = [0]
async def mock_post(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
request = httpx.Request("POST", args[0])
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("Unauthorized", request=request, response=response)
response = MagicMock()
response.text = "mock"
response.raise_for_status = MagicMock()
return response
# Override the runtime decode-response seam before the RPC fires.
client._seams.decode_response = lambda *a, **kw: []
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
start_time = asyncio.get_event_loop().time()
await client.notebooks.list()
elapsed = asyncio.get_event_loop().time() - start_time
# Should have taken at least the delay time
assert elapsed >= 0.09, f"Delay should be applied, elapsed: {elapsed}"
@pytest.mark.asyncio
async def test_no_retry_on_cookie_expiration(self):
"""Test that full cookie expiration is not retried (requires re-login)."""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
client._composed.chain_host._refresh_retry_delay = 0
async def failing_refresh():
# Simulates refresh_auth detecting redirect to login
raise ValueError("Authentication expired. Run 'notebooklm login' to re-authenticate.")
client._collaborators.auth_coord._refresh_callback = failing_refresh
async def mock_post(*args, **kwargs):
request = httpx.Request("POST", args[0])
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("Unauthorized", request=request, response=response)
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
# Should raise the original HTTP error with refresh failure as cause
with pytest.raises(httpx.HTTPStatusError) as exc_info:
await client.notebooks.list()
assert exc_info.value.__cause__ is not None
assert "re-authenticate" in str(exc_info.value.__cause__)
@pytest.mark.asyncio
async def test_http_auth_error_does_not_replay_non_idempotent_write(self):
"""A mid-flight 401 on a non-idempotent create is NOT replayed.
Regression for issue #1157. ``CREATE_NOTEBOOK`` is PROBE_THEN_CREATE,
so ``resolve_effective_disable_internal_retries`` forces the effective
disable flag True. The server may have committed the notebook before
the 401 surfaced, so ``AuthRefreshMiddleware`` must NOT refresh and
re-POST — that would duplicate the notebook. The original auth error
propagates so ``NotebooksAPI.create``'s probe-then-create wrapper can
disambiguate. Driven through the public ``client.notebooks.create``
surface so the regression is pinned end-to-end.
"""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="old_csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
client._composed.chain_host._refresh_retry_delay = 0
refresh_calls = []
async def tracking_refresh():
refresh_calls.append(True)
return client._auth
client._collaborators.auth_coord._refresh_callback = tracking_refresh
create_post_count = [0]
async def mock_post(*args, **kwargs):
url = args[0]
# ``NotebooksAPI.create`` first lists notebooks to capture a
# baseline; that LIST_NOTEBOOKS POST must succeed so only the
# CREATE_NOTEBOOK leg exercises the auth-error path.
if RPCMethod.LIST_NOTEBOOKS.value in str(url):
response = MagicMock()
response.text = "list-ok"
response.raise_for_status = MagicMock()
return response
create_post_count[0] += 1
request = httpx.Request("POST", url)
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("Unauthorized", request=request, response=response)
# Baseline ``list()`` decodes to an empty notebook list; the create's
# decode never runs because the POST raises a 401 first.
client._seams.decode_response = lambda *a, **kw: []
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
with pytest.raises(RPCError):
await client.notebooks.create("My Notebook")
assert refresh_calls == [], "non-idempotent write must not trigger an auth refresh"
assert create_post_count[0] == 1, "CREATE_NOTEBOOK must POST exactly once (no replay)"
@pytest.mark.asyncio
async def test_rpc_auth_error_does_not_replay_non_idempotent_write(self):
"""A decoded auth-shaped ``RPCError`` is NOT replayed for a create.
Regression for issue #1157 — the decode-time refresh-and-retry leg in
``RpcExecutor`` must honor the effective disable classification just
like the HTTP-status leg. ``CREATE_NOTEBOOK`` resolves to disabled
retries, so the decoded auth error surfaces without a second POST.
Driven through the public ``client.notebooks.create`` surface.
"""
auth = AuthTokens(
cookies={"SID": "test"},
csrf_token="old_csrf",
session_id="sid",
)
client = NotebookLMClient(auth)
client._composed.chain_host._refresh_retry_delay = 0
refresh_calls = []
async def tracking_refresh():
refresh_calls.append(True)
return client._auth
client._collaborators.auth_coord._refresh_callback = tracking_refresh
create_post_count = [0]
async def mock_post(*args, **kwargs):
if RPCMethod.CREATE_NOTEBOOK.value in str(args[0]):
create_post_count[0] += 1
response = MagicMock()
response.text = "mock response"
response.raise_for_status = MagicMock()
return response
create_decode_count = [0]
def mock_decode(raw, rpc_id, *args, **kwargs):
# The baseline ``list()`` decodes to an empty list; the create's
# decode raises an auth-shaped RPCError to exercise the
# decode-time refresh-and-retry leg.
if rpc_id == RPCMethod.CREATE_NOTEBOOK.value:
create_decode_count[0] += 1
raise RPCError("Authentication expired")
return []
client._seams.decode_response = mock_decode
async with client:
install_post_as_stream(None, client._collaborators.kernel.get_http_client(), mock_post)
with pytest.raises(RPCError):
await client.notebooks.create("My Notebook")
assert refresh_calls == [], "non-idempotent write must not trigger an auth refresh"
assert create_post_count[0] == 1, "CREATE_NOTEBOOK must POST exactly once (no replay)"
assert create_decode_count[0] == 1, (
"CREATE_NOTEBOOK decode must run once — no retried decode"
)