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

350 lines
14 KiB
Python

"""Unit tests for the ``notebooklm-mcp`` console-script entry point.
These pin the argparse contract of :func:`notebooklm.mcp.__main__.main` so the
``uvx --from "notebooklm-py[mcp]" notebooklm-mcp`` / installed-console-script
distribution path stays wired:
* ``main(["--help"])`` prints argparse help and exits 0, and
* the default invocation wires the documented defaults (stdio transport,
loopback host, INFO log level) through to ``create_server`` / ``server.run``
without touching the network.
The server is stubbed (``create_server`` patched) so no real ``NotebookLMClient``
or transport is constructed — this is a pure CLI-surface test.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
# Skip cleanly when the `mcp` extra (fastmcp) is absent; see conftest.py.
pytest.importorskip("fastmcp")
from notebooklm.mcp import __main__ as entry # noqa: E402 - after importorskip guard
from notebooklm.mcp._host_guard import ( # noqa: E402 - after importorskip guard
LoopbackHostGuardMiddleware,
)
_STRONG_PW = "a-strong-random-password-1234567890"
def _assert_http_run(fake_server: MagicMock, *, host: str, port: int, allow_external: bool) -> None:
"""Assert ``server.run`` got the http transport kwargs + the DNS-rebinding guard."""
kwargs = fake_server.run.call_args.kwargs
assert kwargs["transport"] == "http"
assert kwargs["host"] == host
assert kwargs["port"] == port
assert kwargs["uvicorn_config"] == {"proxy_headers": False}
# The loopback Host guard (#1869) is always installed; allow_external toggles it.
(middleware,) = kwargs["middleware"]
assert middleware.cls is LoopbackHostGuardMiddleware
assert middleware.kwargs == {"allow_external": allow_external}
@pytest.fixture(autouse=True)
def _clear_oauth_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""``main()`` reads the self-hosted-OAuth env on every HTTP run; clear it by
default so an ambient dev/CI environment can't perturb the bearer-focused tests
(the OAuth tests set the vars explicitly after this autouse cleanup)."""
for var in (
"NOTEBOOKLM_MCP_OAUTH_PASSWORD",
"NOTEBOOKLM_MCP_OAUTH_BASE_URL",
"NOTEBOOKLM_MCP_TRUST_PROXY",
):
monkeypatch.delenv(var, raising=False)
def _set_oauth_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NOTEBOOKLM_MCP_OAUTH_PASSWORD", _STRONG_PW)
monkeypatch.setenv("NOTEBOOKLM_MCP_OAUTH_BASE_URL", "https://host.example.com")
def test_help_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
"""``main(["--help"])`` prints argparse help and exits 0."""
with pytest.raises(SystemExit) as excinfo:
entry.main(["--help"])
assert excinfo.value.code == 0
out = capsys.readouterr().out
assert "notebooklm-mcp" in out
assert "--transport" in out
def test_defaults_wire_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None:
"""A bare ``main([])`` builds the server and runs the stdio transport.
Asserts the documented defaults are wired through to ``server.run`` without
constructing a real client or binding any socket.
"""
fake_server = MagicMock()
created: dict[str, object] = {}
def fake_create_server(*, profile: str | None = None, client_factory=None, file_transfer=None):
created["profile"] = profile
return fake_server
monkeypatch.setattr(entry, "create_server", fake_create_server)
# No NOTEBOOKLM_* overrides — exercise the argparse defaults.
for var in (
"NOTEBOOKLM_PROFILE",
"NOTEBOOKLM_MCP_TRANSPORT",
"NOTEBOOKLM_MCP_HOST",
"NOTEBOOKLM_MCP_PORT",
"NOTEBOOKLM_LOG_LEVEL",
):
monkeypatch.delenv(var, raising=False)
entry.main([])
# Default profile is unset (active profile bound at from_storage time).
assert created["profile"] is None
# stdio is the default transport; banner suppressed for clean JSON-RPC stdout.
fake_server.run.assert_called_once_with(transport="stdio", show_banner=False)
def test_explicit_http_transport_binds_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
"""``--transport http`` on loopback binds the port and needs NO token (auth=None)."""
fake_server = MagicMock()
captured: dict[str, object] = {}
def fake_create_server(*, profile=None, client_factory=None, auth=None, file_transfer=None):
captured["auth"] = auth
return fake_server
monkeypatch.setattr(entry, "create_server", fake_create_server)
monkeypatch.delenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", raising=False)
monkeypatch.delenv("NOTEBOOKLM_MCP_TOKEN", raising=False)
entry.main(["--transport", "http", "--host", "127.0.0.1", "--port", "8123"])
_assert_http_run(fake_server, host="127.0.0.1", port=8123, allow_external=False)
# Loopback + no token → unauthenticated (today's local-dev behavior preserved).
assert captured["auth"] is None
def test_http_threads_profile_into_oauth_config(monkeypatch: pytest.MonkeyPatch) -> None:
"""#1765 regression guard: the ``--profile`` flag must reach ``get_oauth_config`` so
OAuth state persists under the profile the server actually drives. Without this, the
entrypoint could silently regress to ``get_oauth_config()`` and the direct unit test
would still pass."""
seen: dict[str, object] = {}
def spy_get_oauth_config(profile: str | None = None):
seen["profile"] = profile
return None # OAuth off → loopback needs no auth, so main() proceeds
monkeypatch.setattr(entry, "get_oauth_config", spy_get_oauth_config)
monkeypatch.setattr(
entry,
"create_server",
lambda *, profile=None, client_factory=None, auth=None, file_transfer=None: MagicMock(),
)
monkeypatch.delenv("NOTEBOOKLM_MCP_TOKEN", raising=False)
monkeypatch.delenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", raising=False)
entry.main(
["--transport", "http", "--host", "127.0.0.1", "--port", "8124", "--profile", "work"]
)
assert seen["profile"] == "work"
def test_http_default_port_is_9420(monkeypatch: pytest.MonkeyPatch) -> None:
"""The default HTTP port is 9420 (no --port / NOTEBOOKLM_MCP_PORT)."""
fake_server = MagicMock()
monkeypatch.setattr(
entry,
"create_server",
lambda *, profile=None, client_factory=None, auth=None, file_transfer=None: fake_server,
)
monkeypatch.delenv("NOTEBOOKLM_MCP_PORT", raising=False)
monkeypatch.delenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", raising=False)
monkeypatch.delenv("NOTEBOOKLM_MCP_TOKEN", raising=False)
entry.main(["--transport", "http"])
_assert_http_run(fake_server, host="127.0.0.1", port=9420, allow_external=False)
def test_http_external_bind_installs_guard_in_bypass_mode(monkeypatch: pytest.MonkeyPatch) -> None:
"""An explicit external bind (which mandates a token) still installs the Host
guard, but in bypass mode (allow_external=True) so the operator can front it."""
fake_server = MagicMock()
monkeypatch.setattr(
entry,
"create_server",
lambda *, profile=None, client_factory=None, auth=None, file_transfer=None: fake_server,
)
monkeypatch.setenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", "1")
monkeypatch.setenv("NOTEBOOKLM_MCP_TOKEN", _STRONG_PW)
entry.main(["--transport", "http", "--host", "0.0.0.0", "--port", "8125"])
_assert_http_run(fake_server, host="0.0.0.0", port=8125, allow_external=True)
def test_http_run_disables_uvicorn_proxy_headers(monkeypatch: pytest.MonkeyPatch) -> None:
"""The HTTP run path must pass uvicorn_config proxy_headers=False so Uvicorn does NOT
rewrite the ASGI peer from X-Forwarded-For — otherwise a request could forge its source
IP and defeat the OAuth login throttle's per-IP keying (which reads request.client.host)."""
fake_server = MagicMock()
monkeypatch.setattr(
entry,
"create_server",
lambda *, profile=None, client_factory=None, auth=None, file_transfer=None: fake_server,
)
monkeypatch.delenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", raising=False)
monkeypatch.delenv("NOTEBOOKLM_MCP_TOKEN", raising=False)
entry.main(["--transport", "http"])
assert fake_server.run.call_args.kwargs["uvicorn_config"] == {"proxy_headers": False}
def test_bogus_transport_env_default_fails_loud(monkeypatch: pytest.MonkeyPatch) -> None:
"""An invalid env-derived transport default must SystemExit, not silently run
stdio (argparse ``choices`` validates an explicit flag but not the env default)."""
monkeypatch.setenv("NOTEBOOKLM_MCP_TRANSPORT", "websocket")
monkeypatch.setattr(entry, "create_server", MagicMock())
with pytest.raises(SystemExit) as excinfo:
entry.main([])
assert "Invalid transport" in str(excinfo.value)
@pytest.mark.parametrize(
("host", "expected"),
[
("127.0.0.1", True),
("::1", True),
("localhost", True),
("LOCALHOST", True), # hostnames are case-insensitive
(" localhost ", True), # surrounding whitespace tolerated
("0.0.0.0", False), # all-interfaces is NOT loopback → token required
("::", False),
("example.com", False), # public DNS name → fail closed
("192.168.1.5", False), # LAN address → not loopback
],
)
def test_is_loopback_classification(host: str, expected: bool) -> None:
assert entry._is_loopback(host) is expected
def test_http_loopback_with_token_attaches_auth(monkeypatch: pytest.MonkeyPatch) -> None:
"""Loopback + a token set → auth IS attached (the safe, more-restrictive
interaction): setting the token opts even a loopback bind into the gate."""
fake_server = MagicMock()
captured: dict[str, object] = {}
def fake_create_server(*, profile=None, client_factory=None, auth=None, file_transfer=None):
captured["auth"] = auth
return fake_server
monkeypatch.setattr(entry, "create_server", fake_create_server)
monkeypatch.delenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", raising=False)
monkeypatch.setenv("NOTEBOOKLM_MCP_TOKEN", "loopback-token")
entry.main(["--transport", "http", "--host", "127.0.0.1", "--port", "8124"])
from notebooklm.mcp._auth import McpBearerAuthProvider
assert isinstance(captured["auth"], McpBearerAuthProvider)
def test_http_non_loopback_without_token_refuses(monkeypatch: pytest.MonkeyPatch) -> None:
"""Fail closed: a network-reachable bind without a token must not start —
even with the external-bind override set, the server is built nowhere."""
built = MagicMock(side_effect=AssertionError("create_server must not be reached"))
monkeypatch.setattr(entry, "create_server", built)
monkeypatch.setenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", "1")
monkeypatch.delenv("NOTEBOOKLM_MCP_TOKEN", raising=False)
with pytest.raises(SystemExit) as excinfo:
entry.main(["--transport", "http", "--host", "0.0.0.0", "--port", "8000"])
assert "NOTEBOOKLM_MCP_TOKEN" in str(excinfo.value)
built.assert_not_called()
def test_http_non_loopback_with_token_attaches_auth(monkeypatch: pytest.MonkeyPatch) -> None:
"""A network bind WITH a token builds the server with a bearer auth provider."""
from notebooklm.mcp._auth import McpBearerAuthProvider
fake_server = MagicMock()
captured: dict[str, object] = {}
def fake_create_server(*, profile=None, client_factory=None, auth=None, file_transfer=None):
captured["auth"] = auth
return fake_server
monkeypatch.setattr(entry, "create_server", fake_create_server)
monkeypatch.setenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", "1")
monkeypatch.setenv("NOTEBOOKLM_MCP_TOKEN", "s3cret-token")
entry.main(["--transport", "http", "--host", "0.0.0.0", "--port", "8000"])
_assert_http_run(fake_server, host="0.0.0.0", port=8000, allow_external=True)
assert isinstance(captured["auth"], McpBearerAuthProvider)
def test_http_non_loopback_with_oauth_only_satisfies_gate(monkeypatch: pytest.MonkeyPatch) -> None:
"""A network bind with self-hosted OAuth configured (no bearer) starts and attaches
the OAuth provider — OAuth alone satisfies the fail-closed auth requirement."""
from notebooklm.mcp._auth import McpBearerAuthProvider
fake_server = MagicMock()
captured: dict[str, object] = {}
def fake_create_server(*, profile=None, client_factory=None, auth=None, file_transfer=None):
captured["auth"] = auth
return fake_server
monkeypatch.setattr(entry, "create_server", fake_create_server)
monkeypatch.setenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", "1")
monkeypatch.delenv("NOTEBOOKLM_MCP_TOKEN", raising=False)
_set_oauth_env(monkeypatch)
entry.main(["--transport", "http", "--host", "0.0.0.0", "--port", "8000"])
fake_server.run.assert_called_once()
auth = captured["auth"]
assert auth is not None and not isinstance(auth, McpBearerAuthProvider) # the OAuth provider
def test_http_non_loopback_with_oauth_and_bearer_composes_multiauth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Bearer + self-hosted OAuth → MultiAuth (claude.ai uses OAuth, Claude Code the bearer)."""
from fastmcp.server.auth import MultiAuth
fake_server = MagicMock()
captured: dict[str, object] = {}
def fake_create_server(*, profile=None, client_factory=None, auth=None, file_transfer=None):
captured["auth"] = auth
return fake_server
monkeypatch.setattr(entry, "create_server", fake_create_server)
monkeypatch.setenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", "1")
monkeypatch.setenv("NOTEBOOKLM_MCP_TOKEN", "s3cret-token")
_set_oauth_env(monkeypatch)
entry.main(["--transport", "http", "--host", "0.0.0.0", "--port", "8000"])
assert isinstance(captured["auth"], MultiAuth)
def test_http_non_loopback_partial_oauth_refuses(monkeypatch: pytest.MonkeyPatch) -> None:
"""Partial OAuth config (password without base URL) fails closed at startup."""
built = MagicMock(side_effect=AssertionError("create_server must not be reached"))
monkeypatch.setattr(entry, "create_server", built)
monkeypatch.setenv("NOTEBOOKLM_MCP_ALLOW_EXTERNAL_BIND", "1")
monkeypatch.delenv("NOTEBOOKLM_MCP_TOKEN", raising=False)
monkeypatch.setenv("NOTEBOOKLM_MCP_OAUTH_PASSWORD", _STRONG_PW) # base URL missing
with pytest.raises(SystemExit):
entry.main(["--transport", "http", "--host", "0.0.0.0", "--port", "8000"])
built.assert_not_called()