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

414 lines
15 KiB
Python

"""Unit tests for the transport-neutral ``notebooklm._app.source_add`` core.
These pin the relocated ``source add`` business logic at the ``_app`` boundary
(independent of the Click adapter):
* :func:`validate_url` — the SSRF / local-file-read guard (scheme allowlist,
no-host rejection, private/loopback/link-local/unspecified IP rejection,
``localhost`` spelling rejection, the ``allow_internal`` bypass).
* :func:`looks_like_path` — slash / known-extension path heuristic.
* :func:`validate_upload_path` — symlink refusal + regular-file check.
* :func:`build_source_add_plan` — input-mode detection (url / youtube / file /
text), warning collection, and the gate that explicit ``--type`` still honours.
* :func:`add_source` / :func:`execute_source_add` — the add-workflow dispatch
to ``add_url`` / ``add_text`` / ``add_file``.
Pure-service tests (no Click / CliRunner): the command-layer wiring is exercised
in ``tests/unit/cli/test_source.py``.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from notebooklm._app.source_add import (
SourceAddExecutionPlan,
SourceAddPlan,
SourceAddResult,
SourceAddValidationError,
add_source,
build_source_add_plan,
execute_source_add,
looks_like_path,
validate_upload_path,
validate_url,
)
from notebooklm.exceptions import ValidationError
from notebooklm.types import Source
# ===========================================================================
# validate_url — SSRF / local-file-read guard
# ===========================================================================
class TestValidateUrl:
@pytest.mark.parametrize(
"url",
[
"http://example.com/a",
"https://example.com/a",
"HTTPS://Example.com/a", # scheme case-insensitive
"https://8.8.8.8/path", # public IP literal
],
)
def test_accepts_public_http_urls(self, url: str) -> None:
# Should not raise.
validate_url(url, allow_internal=False)
@pytest.mark.parametrize(
"scheme",
["file", "ftp", "gopher", "data", "javascript"],
)
def test_rejects_disallowed_schemes(self, scheme: str) -> None:
with pytest.raises(SourceAddValidationError) as exc:
validate_url(f"{scheme}://etc/passwd", allow_internal=False)
assert "scheme" in str(exc.value).lower()
def test_rejects_disallowed_scheme_even_with_allow_internal(self) -> None:
# The scheme allowlist still applies under allow_internal.
with pytest.raises(SourceAddValidationError):
validate_url("file:///etc/passwd", allow_internal=True)
def test_rejects_missing_host(self) -> None:
with pytest.raises(SourceAddValidationError) as exc:
validate_url("http:///path", allow_internal=False)
assert "no host" in str(exc.value).lower()
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:8080/x", # loopback
"http://10.0.0.1/x", # private (RFC1918)
"http://192.168.1.1/x", # private
"http://169.254.169.254/latest", # link-local (cloud metadata)
"http://0.0.0.0/x", # unspecified
"http://127.1/x", # legacy numeric IPv4 spelling
"http://localhost/x", # localhost literal
"http://foo.localhost/x", # localhost suffix
],
)
def test_rejects_internal_hosts(self, url: str) -> None:
with pytest.raises(SourceAddValidationError):
validate_url(url, allow_internal=False)
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:8080/x",
"http://10.0.0.1/x",
"http://localhost/x",
],
)
def test_allow_internal_bypasses_host_check(self, url: str) -> None:
# Should not raise once the host check is opted out.
validate_url(url, allow_internal=True)
def test_dns_name_is_accepted_without_resolution(self) -> None:
# Non-localhost DNS names pass at this layer (no DNS resolution here).
validate_url("http://internal-but-named.example/x", allow_internal=False)
def test_validation_error_is_public_validation_error_subclass(self) -> None:
# _app.errors.classify covers it uniformly via the public base.
assert issubclass(SourceAddValidationError, ValidationError)
# ===========================================================================
# looks_like_path — path heuristic
# ===========================================================================
class TestLooksLikePath:
@pytest.mark.parametrize(
"content",
["docs/file.pdf", "C:\\foo\\bar", "report.pdf", "notes.md", "data.csv"],
)
def test_path_shaped_content(self, content: str) -> None:
assert looks_like_path(content) is True
@pytest.mark.parametrize(
"content",
["just some text", "hello world", "a sentence."],
)
def test_non_path_content(self, content: str) -> None:
assert looks_like_path(content) is False
# ===========================================================================
# validate_upload_path — symlink + regular-file checks
# ===========================================================================
class TestValidateUploadPath:
def test_accepts_regular_file(self, tmp_path: Path) -> None:
f = tmp_path / "doc.pdf"
f.write_text("x")
resolved = validate_upload_path(str(f), follow_symlinks=False)
assert resolved == f.resolve()
def test_rejects_non_regular_file(self, tmp_path: Path) -> None:
with pytest.raises(SourceAddValidationError) as exc:
validate_upload_path(str(tmp_path / "missing.pdf"), follow_symlinks=False)
assert "regular file" in str(exc.value).lower()
def test_rejects_symlink_without_follow(self, tmp_path: Path) -> None:
target = tmp_path / "real.pdf"
target.write_text("x")
link = tmp_path / "link.pdf"
link.symlink_to(target)
with pytest.raises(SourceAddValidationError) as exc:
validate_upload_path(str(link), follow_symlinks=False)
assert "symlink" in str(exc.value).lower()
def test_follows_symlink_when_opted_in(self, tmp_path: Path) -> None:
target = tmp_path / "real.pdf"
target.write_text("x")
link = tmp_path / "link.pdf"
link.symlink_to(target)
resolved = validate_upload_path(str(link), follow_symlinks=True)
assert resolved == target.resolve()
# ===========================================================================
# build_source_add_plan — input detection + warning collection
# ===========================================================================
def _validate_path_stub(content: str, follow_symlinks: bool) -> Path:
return Path(content)
class TestBuildSourceAddPlan:
def test_autodetect_url(self) -> None:
plan = build_source_add_plan(
content="https://example.com/a",
source_type=None,
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
assert plan.detected_type == "url"
assert plan.upload_path is None
def test_autodetect_youtube(self) -> None:
plan = build_source_add_plan(
content="https://www.youtube.com/watch?v=abc123",
source_type=None,
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
assert plan.detected_type == "youtube"
def test_autodetect_plain_text_default_title(self) -> None:
plan = build_source_add_plan(
content="just a note",
source_type=None,
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
assert plan.detected_type == "text"
assert plan.title == "Pasted Text"
assert plan.warnings == ()
def test_autodetect_path_shaped_missing_warns_and_falls_back_to_text(self) -> None:
plan = build_source_add_plan(
content="docs/missing.pdf",
source_type=None,
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
assert plan.detected_type == "text"
assert len(plan.warnings) == 1
assert "looks like a path" in plan.warnings[0]
def test_autodetect_existing_file(self, tmp_path: Path) -> None:
f = tmp_path / "doc.pdf"
f.write_text("x")
plan = build_source_add_plan(
content=str(f),
source_type=None,
title=None,
mime_type="application/pdf",
follow_symlinks=False,
validate_path=validate_upload_path,
looks_path_shaped=looks_like_path,
)
assert plan.detected_type == "file"
assert plan.upload_path == f.resolve()
assert plan.mime_type == "application/pdf"
def test_internal_url_rejected_before_type_binding(self) -> None:
# A bad host must raise before url-vs-youtube is even decided.
with pytest.raises(SourceAddValidationError):
build_source_add_plan(
content="http://127.0.0.1:8080/x",
source_type=None,
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
def test_explicit_type_url_still_validates(self) -> None:
# ``--type url file:///etc/passwd`` must not skip the gate.
with pytest.raises(SourceAddValidationError):
build_source_add_plan(
content="file:///etc/passwd",
source_type="url",
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
def test_explicit_type_youtube_validates_internal_host(self) -> None:
# ``--type youtube`` honours the same internal-host gate.
with pytest.raises(SourceAddValidationError):
build_source_add_plan(
content="http://127.0.0.1/x",
source_type="youtube",
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
def test_allow_internal_threaded_through(self) -> None:
plan = build_source_add_plan(
content="http://127.0.0.1:8080/x",
source_type=None,
title=None,
mime_type=None,
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
allow_internal=True,
)
assert plan.detected_type == "url"
def test_explicit_type_file_validates_path(self, tmp_path: Path) -> None:
f = tmp_path / "doc.pdf"
f.write_text("x")
plan = build_source_add_plan(
content=str(f),
source_type="file",
title="My Doc",
mime_type=None,
follow_symlinks=False,
validate_path=validate_upload_path,
looks_path_shaped=looks_like_path,
)
assert plan.detected_type == "file"
assert plan.upload_path == f.resolve()
assert plan.title == "My Doc"
def test_mime_type_only_kept_for_file(self) -> None:
# A non-file detection drops the mime_type.
plan = build_source_add_plan(
content="just text",
source_type="text",
title=None,
mime_type="application/pdf",
follow_symlinks=False,
validate_path=_validate_path_stub,
looks_path_shaped=looks_like_path,
)
assert plan.detected_type == "text"
assert plan.mime_type is None
# ===========================================================================
# add_source / execute_source_add — dispatch
# ===========================================================================
def _make_sources_facade() -> MagicMock:
facade = MagicMock()
facade.add_url = AsyncMock(return_value=Source(id="s_url", title="URL"))
facade.add_text = AsyncMock(return_value=Source(id="s_txt", title="Text"))
facade.add_file = AsyncMock(return_value=Source(id="s_file", title="File"))
return facade
@pytest.mark.asyncio
async def test_add_source_url_dispatch() -> None:
facade = _make_sources_facade()
plan = SourceAddPlan(
content="https://ex.com/a", detected_type="url", title=None, upload_path=None
)
src = await add_source(facade, notebook_id="nb_1", plan=plan)
assert src.id == "s_url"
facade.add_url.assert_awaited_once_with("nb_1", "https://ex.com/a")
@pytest.mark.asyncio
async def test_add_source_youtube_uses_add_url() -> None:
facade = _make_sources_facade()
plan = SourceAddPlan(
content="https://youtu.be/abc", detected_type="youtube", title=None, upload_path=None
)
await add_source(facade, notebook_id="nb_1", plan=plan)
facade.add_url.assert_awaited_once_with("nb_1", "https://youtu.be/abc")
@pytest.mark.asyncio
async def test_add_source_text_dispatch_default_title() -> None:
facade = _make_sources_facade()
plan = SourceAddPlan(content="some text", detected_type="text", title=None, upload_path=None)
await add_source(facade, notebook_id="nb_1", plan=plan)
facade.add_text.assert_awaited_once_with("nb_1", "Untitled", "some text")
@pytest.mark.asyncio
async def test_add_source_file_dispatch() -> None:
facade = _make_sources_facade()
plan = SourceAddPlan(
content="ignored",
detected_type="file",
title="My File",
upload_path=Path("/tmp/doc.pdf"),
mime_type="application/pdf",
)
await add_source(facade, notebook_id="nb_1", plan=plan)
# ``add_source`` forwards ``str(plan.upload_path)``; compare against the same
# ``str(Path(...))`` so the assertion holds on Windows (``\tmp\doc.pdf``) too.
facade.add_file.assert_awaited_once_with(
"nb_1", str(Path("/tmp/doc.pdf")), "application/pdf", title="My File"
)
@pytest.mark.asyncio
async def test_add_source_file_without_path_raises() -> None:
facade = _make_sources_facade()
plan = SourceAddPlan(content="x", detected_type="file", title=None, upload_path=None)
with pytest.raises(SourceAddValidationError):
await add_source(facade, notebook_id="nb_1", plan=plan)
@pytest.mark.asyncio
async def test_execute_source_add_returns_typed_result() -> None:
client = MagicMock()
client.sources = _make_sources_facade()
plan = SourceAddPlan(
content="https://ex.com/a", detected_type="url", title=None, upload_path=None
)
result = await execute_source_add(client, SourceAddExecutionPlan(notebook_id="nb_1", plan=plan))
assert isinstance(result, SourceAddResult)
assert result.source.id == "s_url"