Files
wehub-resource-sync 09e9f3545f
CodeQL / Analyze (push) Waiting to run
dependency-audit / pip-audit (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:30:13 +08:00

250 lines
10 KiB
Python

"""U1/U2: ``notebooklm-server`` launcher guards (bind + token fail-closed)."""
from __future__ import annotations
import logging
import os
from unittest.mock import MagicMock
import pytest
from notebooklm.server import __main__ as launcher
from notebooklm.server._auth import SERVER_TOKEN_ENV
def test_refuses_non_loopback_host_without_override() -> None:
with pytest.raises(SystemExit):
launcher._check_bind_allowed("0.0.0.0", allow_external=False)
def test_accepts_loopback_host() -> None:
launcher._check_bind_allowed("127.0.0.1", allow_external=False)
launcher._check_bind_allowed("localhost", allow_external=False)
def test_accepts_non_loopback_with_override() -> None:
launcher._check_bind_allowed("203.0.113.5", allow_external=True)
def test_refuses_empty_host_even_with_override() -> None:
with pytest.raises(SystemExit):
launcher._check_bind_allowed("", allow_external=True)
def test_refuses_to_start_without_a_token(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(SERVER_TOKEN_ENV, raising=False)
with pytest.raises(SystemExit):
launcher._check_token_configured()
def test_token_present_allows_start(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(SERVER_TOKEN_ENV, "secret")
launcher._check_token_configured() # no raise
def test_bad_port_fails_clean() -> None:
with pytest.raises(SystemExit):
launcher._resolve_port("not-a-number")
assert launcher._resolve_port("8123") == 8123
@pytest.mark.parametrize("raw", ["-1", "65536", "70000"])
def test_out_of_range_port_fails_clean(raw: str) -> None:
# An in-range-int-but-out-of-socket-range port fails at parse time with a
# clear message, not later at bind time.
with pytest.raises(SystemExit):
launcher._resolve_port(raw)
@pytest.mark.parametrize("raw,expected", [("0", 0), ("65535", 65535)])
def test_boundary_ports_accepted(raw: str, expected: int) -> None:
assert launcher._resolve_port(raw) == expected
def test_is_loopback_rejects_non_ip_hostname() -> None:
# A hostname that is neither in _LOOPBACK_HOSTNAMES nor a numeric IP literal
# is treated as non-loopback (the ipaddress.ip_address ValueError branch).
assert launcher._is_loopback("example.com") is False
# --- _build_parser: defaults, env-derived defaults, flag overrides -----------
def test_build_parser_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
for var in ("NOTEBOOKLM_SERVER_HOST", "NOTEBOOKLM_SERVER_PORT", "NOTEBOOKLM_LOG_LEVEL"):
monkeypatch.delenv(var, raising=False)
monkeypatch.delenv(SERVER_TOKEN_ENV, raising=False)
monkeypatch.delenv(launcher.SERVER_TOKEN_FILE_ENV, raising=False)
args = launcher._build_parser().parse_args([])
assert args.host == "127.0.0.1"
assert args.port == "8000" # kept as a string; converted later by _resolve_port
assert args.token is None # deprecated flag defaults to None (never env-seeded)
assert args.token_file is None
assert args.log_level == "INFO"
def test_build_parser_reads_env_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NOTEBOOKLM_SERVER_HOST", "::1")
monkeypatch.setenv("NOTEBOOKLM_SERVER_PORT", "9001")
monkeypatch.setenv("NOTEBOOKLM_LOG_LEVEL", "DEBUG")
# The bearer token no longer feeds --token (deprecated); --token-file reads its
# own env default instead.
monkeypatch.setenv(launcher.SERVER_TOKEN_FILE_ENV, "/etc/nblm/token")
args = launcher._build_parser().parse_args([])
assert args.host == "::1"
assert args.port == "9001"
assert args.token is None
assert args.token_file == "/etc/nblm/token"
assert args.log_level == "DEBUG"
def test_build_parser_flags_override_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NOTEBOOKLM_SERVER_HOST", "::1")
args = launcher._build_parser().parse_args(["--host", "127.0.0.1", "--port", "8123"])
assert args.host == "127.0.0.1"
assert args.port == "8123"
def test_build_parser_profile_default_env_flag(monkeypatch: pytest.MonkeyPatch) -> None:
# Default is None (the parser reads NOTEBOOKLM_PROFILE, so clear it first).
monkeypatch.delenv("NOTEBOOKLM_PROFILE", raising=False)
assert launcher._build_parser().parse_args([]).profile is None
# Env supplies the default...
monkeypatch.setenv("NOTEBOOKLM_PROFILE", "envprof")
assert launcher._build_parser().parse_args([]).profile == "envprof"
# ...and the flag overrides the env.
assert launcher._build_parser().parse_args(["--profile", "work"]).profile == "work"
# --- _configure_logging: maps the level name, falls back to INFO ------------
def test_configure_logging_maps_level(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
monkeypatch.setattr(launcher.logging, "basicConfig", lambda **kw: captured.update(kw))
launcher._configure_logging("debug")
assert captured["level"] == logging.DEBUG
launcher._configure_logging("not-a-level") # unknown names fall back to INFO
assert captured["level"] == logging.INFO
# --- main(): full launch path with uvicorn.run stubbed out ------------------
def _stub_uvicorn_run(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]:
"""Stub out ``main()``'s side effects and capture the ``uvicorn.run`` call.
Replaces ``uvicorn.run`` (so no server actually binds), ``create_app`` (so no
real app is built), and the consumer-side ``logging`` binding with a wrapper
whose ``basicConfig`` is a no-op (so ``main()`` does not reconfigure logging
— without mutating the real ``logging`` module). Returns the dict the
``uvicorn.run`` call is captured into.
"""
import uvicorn
captured: dict[str, object] = {}
def _capture(app: object, **kwargs: object) -> None:
captured["app"] = app
captured.update(kwargs)
# Wrap the real module so getLogger()/level constants still work, but swallow
# basicConfig — patching launcher's binding, not the global logging module.
mock_logging = MagicMock(wraps=logging)
mock_logging.basicConfig = lambda **_kwargs: None
monkeypatch.setattr(uvicorn, "run", _capture)
monkeypatch.setattr(launcher, "create_app", MagicMock(return_value="APP"))
monkeypatch.setattr(launcher, "logging", mock_logging)
return captured
def test_main_runs_server_with_resolved_args(monkeypatch: pytest.MonkeyPatch) -> None:
captured = _stub_uvicorn_run(monkeypatch)
monkeypatch.setenv(SERVER_TOKEN_ENV, "env-secret")
launcher.main(["--host", "127.0.0.1", "--port", "8123", "--log-level", "WARNING"])
assert captured["app"] == "APP"
assert captured["host"] == "127.0.0.1"
assert captured["port"] == 8123 # _resolve_port converts the string to an int
assert captured["log_level"] == "warning" # uvicorn wants a lowercase level
# Default (loopback) mode: proxy headers OFF so request.client.host is the real
# socket peer (the loopback guard's basis), not a spoofable X-Forwarded-For.
assert captured["proxy_headers"] is False
def test_main_threads_profile_into_create_app(monkeypatch: pytest.MonkeyPatch) -> None:
"""#1769: --profile must reach create_app so the server binds the right profile."""
_stub_uvicorn_run(monkeypatch) # patches launcher.create_app with a MagicMock
monkeypatch.setenv(SERVER_TOKEN_ENV, "env-secret")
monkeypatch.delenv("NOTEBOOKLM_PROFILE", raising=False)
launcher.main(["--host", "127.0.0.1", "--profile", "work"])
launcher.create_app.assert_called_once_with(profile="work") # type: ignore[attr-defined]
def test_main_rejects_deprecated_token_flag(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_uvicorn_run(monkeypatch)
monkeypatch.setenv(SERVER_TOKEN_ENV, "env-secret")
# --token leaks via `ps`; it is refused outright even when a valid env token
# would otherwise let the server start.
with pytest.raises(SystemExit):
launcher.main(["--host", "127.0.0.1", "--token", "argv-secret"])
def test_main_loads_token_from_file(monkeypatch: pytest.MonkeyPatch, tmp_path: object) -> None:
from pathlib import Path
_stub_uvicorn_run(monkeypatch)
monkeypatch.delenv(SERVER_TOKEN_ENV, raising=False)
token_path = Path(str(tmp_path)) / "token.txt"
token_path.write_text("file-token\n", encoding="utf-8")
launcher.main(["--host", "127.0.0.1", "--token-file", str(token_path)])
# The file's contents (stripped) seed the env the auth dependency reads; only
# the PATH ever appeared on argv.
assert os.environ[SERVER_TOKEN_ENV] == "file-token"
def test_main_empty_token_file_refuses(monkeypatch: pytest.MonkeyPatch, tmp_path: object) -> None:
from pathlib import Path
_stub_uvicorn_run(monkeypatch)
monkeypatch.delenv(SERVER_TOKEN_ENV, raising=False)
token_path = Path(str(tmp_path)) / "empty.txt"
token_path.write_text(" \n", encoding="utf-8")
with pytest.raises(SystemExit):
launcher.main(["--host", "127.0.0.1", "--token-file", str(token_path)])
def test_main_refuses_without_token(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_uvicorn_run(monkeypatch)
monkeypatch.delenv(SERVER_TOKEN_ENV, raising=False)
with pytest.raises(SystemExit):
launcher.main(["--host", "127.0.0.1"])
def test_main_refuses_non_loopback_host(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_uvicorn_run(monkeypatch)
monkeypatch.setenv(SERVER_TOKEN_ENV, "env-secret")
monkeypatch.delenv(launcher.ALLOW_EXTERNAL_BIND_ENV, raising=False)
with pytest.raises(SystemExit):
launcher.main(["--host", "0.0.0.0"])
def test_main_allows_external_bind_with_optin(monkeypatch: pytest.MonkeyPatch) -> None:
captured = _stub_uvicorn_run(monkeypatch)
monkeypatch.setenv(SERVER_TOKEN_ENV, "env-secret")
monkeypatch.setenv(launcher.ALLOW_EXTERNAL_BIND_ENV, "1")
launcher.main(["--host", "0.0.0.0"])
assert captured["host"] == "0.0.0.0"
# External-bind opt-in (behind a trusted proxy): forwarded headers are honored.
assert captured["proxy_headers"] is True
def test_meta_server_name_matches_app_server_name() -> None:
# meta.SERVER_NAME is named locally (not imported) to avoid a circular import
# with server.app; pin the two equal so they can never drift (the comment in
# meta.py points here — enforce, don't document).
from notebooklm.server.app import SERVER_NAME as app_name
from notebooklm.server.routes.meta import SERVER_NAME as meta_name
assert meta_name == app_name