mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
413 lines
16 KiB
Python
413 lines
16 KiB
Python
"""Tests for MCP auth state persistence tools (state_save / state_load)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from skyvern.cli.core.browser_ops import _cookie_domain_matches
|
|
from skyvern.cli.core.result import BrowserContext
|
|
from skyvern.cli.core.session_manager import SessionState
|
|
from skyvern.cli.mcp_tools import state as mcp_state
|
|
from skyvern.cli.mcp_tools.state import _validate_state_path
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# Helpers
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
|
|
def _make_mock_page(url: str = "https://example.com", title: str = "Example") -> MagicMock:
|
|
page = MagicMock()
|
|
page.url = url
|
|
page.title = AsyncMock(return_value=title)
|
|
page.evaluate = AsyncMock(return_value={})
|
|
page.is_closed.return_value = False
|
|
return page
|
|
|
|
|
|
def _make_mock_browser(cookies: list | None = None) -> MagicMock:
|
|
browser = MagicMock()
|
|
browser._browser_context = MagicMock()
|
|
browser._browser_context.cookies = AsyncMock(return_value=cookies or [])
|
|
browser._browser_context.add_cookies = AsyncMock()
|
|
return browser
|
|
|
|
|
|
def _make_session_state(browser: MagicMock | None = None) -> SessionState:
|
|
state = SessionState()
|
|
state.browser = browser
|
|
return state
|
|
|
|
|
|
def _patch_get_page(monkeypatch: pytest.MonkeyPatch, page: MagicMock, ctx: BrowserContext) -> AsyncMock:
|
|
skyvern_page = SimpleNamespace(page=page)
|
|
mock = AsyncMock(return_value=(skyvern_page, ctx))
|
|
monkeypatch.setattr(mcp_state, "get_page", mock)
|
|
return mock
|
|
|
|
|
|
def _patch_session(monkeypatch: pytest.MonkeyPatch, state: SessionState) -> MagicMock:
|
|
mock = MagicMock(return_value=state)
|
|
monkeypatch.setattr(mcp_state, "get_current_session", mock)
|
|
return mock
|
|
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# _cookie_domain_matches
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
|
|
class TestCookieDomainMatches:
|
|
def test_exact_match(self) -> None:
|
|
assert _cookie_domain_matches("example.com", "example.com") is True
|
|
|
|
def test_subdomain_match_with_dot(self) -> None:
|
|
assert _cookie_domain_matches(".example.com", "sub.example.com") is True
|
|
|
|
def test_subdomain_match_without_dot(self) -> None:
|
|
assert _cookie_domain_matches("example.com", "sub.example.com") is True
|
|
|
|
def test_suffix_attack_rejected(self) -> None:
|
|
assert _cookie_domain_matches("example.com", "evil-example.com") is False
|
|
|
|
def test_empty_cookie_domain(self) -> None:
|
|
assert _cookie_domain_matches("", "example.com") is False
|
|
|
|
def test_empty_page_domain(self) -> None:
|
|
assert _cookie_domain_matches("example.com", "") is False
|
|
|
|
def test_both_empty(self) -> None:
|
|
assert _cookie_domain_matches("", "") is False
|
|
|
|
def test_dot_only_cookie_domain(self) -> None:
|
|
assert _cookie_domain_matches(".", "example.com") is False
|
|
|
|
def test_deep_subdomain_match(self) -> None:
|
|
assert _cookie_domain_matches(".example.com", "a.b.c.example.com") is True
|
|
|
|
def test_different_domain_rejected(self) -> None:
|
|
assert _cookie_domain_matches("other.com", "example.com") is False
|
|
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# _validate_state_path
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
|
|
class TestValidateStatePath:
|
|
def test_valid_path_in_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
result = _validate_state_path("state.json")
|
|
assert result == (tmp_path / "state.json").resolve()
|
|
|
|
def test_valid_path_no_extension(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
result = _validate_state_path("mystate")
|
|
assert result == (tmp_path / "mystate").resolve()
|
|
|
|
def test_rejects_outside_allowed_roots(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError, match="must be under working directory"):
|
|
_validate_state_path("/etc/passwd")
|
|
|
|
def test_rejects_path_traversal(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError, match="must be under working directory"):
|
|
_validate_state_path("../../../etc/passwd")
|
|
|
|
def test_rejects_symlinks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
target = tmp_path / "real.json"
|
|
target.write_text("{}")
|
|
link = tmp_path / "link.json"
|
|
link.symlink_to(target)
|
|
with pytest.raises(ValueError, match="Symlinks not allowed"):
|
|
_validate_state_path("link.json")
|
|
|
|
def test_rejects_bad_extension(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(ValueError, match="must have .json extension"):
|
|
_validate_state_path("state.exe")
|
|
|
|
def test_must_exist_raises_when_missing(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
with pytest.raises(FileNotFoundError, match="State file not found"):
|
|
_validate_state_path("missing.json", must_exist=True)
|
|
|
|
def test_must_exist_passes_when_present(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
f = tmp_path / "exists.json"
|
|
f.write_text("{}")
|
|
result = _validate_state_path("exists.json", must_exist=True)
|
|
assert result == f.resolve()
|
|
|
|
def test_home_skyvern_path_allowed(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
skyvern_dir = tmp_path / ".skyvern"
|
|
skyvern_dir.mkdir()
|
|
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
|
|
monkeypatch.chdir(tmp_path / "elsewhere" if (tmp_path / "elsewhere").exists() else tmp_path)
|
|
result = _validate_state_path(str(skyvern_dir / "state.json"))
|
|
assert ".skyvern" in str(result)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# skyvern_state_save
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_save_happy_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
cookies = [{"name": "sid", "value": "abc", "domain": "example.com", "path": "/"}]
|
|
local_storage = {"key1": "val1"}
|
|
session_storage = {"skey": "sval"}
|
|
|
|
page = _make_mock_page("https://example.com")
|
|
page.evaluate = AsyncMock(side_effect=[local_storage, session_storage])
|
|
|
|
browser = _make_mock_browser(cookies)
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
result = await mcp_state.skyvern_state_save(file_path="auth.json")
|
|
|
|
assert result["ok"] is True
|
|
assert result["data"]["cookie_count"] == 1
|
|
assert result["data"]["local_storage_count"] == 1
|
|
assert result["data"]["session_storage_count"] == 1
|
|
|
|
saved = json.loads((tmp_path / "auth.json").read_text())
|
|
assert saved["version"] == 1
|
|
assert saved["cookies"] == cookies
|
|
assert saved["local_storage"] == local_storage
|
|
assert saved["session_storage"] == session_storage
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_save_no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from skyvern.cli.mcp_tools._session import BrowserNotAvailableError
|
|
|
|
monkeypatch.setattr(mcp_state, "get_page", AsyncMock(side_effect=BrowserNotAvailableError()))
|
|
result = await mcp_state.skyvern_state_save(file_path="test.json")
|
|
assert result["ok"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_save_invalid_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
page = _make_mock_page()
|
|
browser = _make_mock_browser()
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
result = await mcp_state.skyvern_state_save(file_path="/etc/evil.json")
|
|
assert result["ok"] is False
|
|
assert "must be under" in result["error"]["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_save_no_browser_in_session(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
page = _make_mock_page()
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(None))
|
|
|
|
result = await mcp_state.skyvern_state_save(file_path="auth.json")
|
|
assert result["ok"] is False
|
|
|
|
|
|
# ═══════════════════════════════════════════════════
|
|
# skyvern_state_load
|
|
# ═══════════════════════════════════════════════════
|
|
|
|
|
|
def _write_state_file(path: Path, *, cookies: list | None = None, url: str = "https://example.com") -> None:
|
|
state = {
|
|
"version": 1,
|
|
"url": url,
|
|
"timestamp": "2026-04-01T00:00:00+00:00",
|
|
"cookies": cookies or [],
|
|
"local_storage": {"lk": "lv"},
|
|
"session_storage": {"sk": "sv"},
|
|
}
|
|
path.write_text(json.dumps(state))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_load_happy_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
cookies = [
|
|
{"name": "sid", "value": "abc", "domain": "example.com", "path": "/"},
|
|
{"name": "other", "value": "xyz", "domain": "evil.com", "path": "/"},
|
|
]
|
|
state_file = tmp_path / "auth.json"
|
|
_write_state_file(state_file, cookies=cookies)
|
|
|
|
page = _make_mock_page("https://example.com")
|
|
browser = _make_mock_browser()
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
result = await mcp_state.skyvern_state_load(file_path="auth.json")
|
|
|
|
assert result["ok"] is True
|
|
assert result["data"]["cookie_count"] == 1
|
|
assert result["data"]["skipped_cookies"] == 1
|
|
assert result["data"]["local_storage_count"] == 1
|
|
assert result["data"]["session_storage_count"] == 1
|
|
|
|
browser._browser_context.add_cookies.assert_awaited_once()
|
|
added = browser._browser_context.add_cookies.call_args[0][0]
|
|
assert len(added) == 1
|
|
assert added[0]["domain"] == "example.com"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_load_no_browser(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from skyvern.cli.mcp_tools._session import BrowserNotAvailableError
|
|
|
|
monkeypatch.setattr(mcp_state, "get_page", AsyncMock(side_effect=BrowserNotAvailableError()))
|
|
result = await mcp_state.skyvern_state_load(file_path="test.json")
|
|
assert result["ok"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_load_file_not_found(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
page = _make_mock_page()
|
|
browser = _make_mock_browser()
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
result = await mcp_state.skyvern_state_load(file_path="nonexistent.json")
|
|
assert result["ok"] is False
|
|
assert "not found" in result["error"]["message"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_load_bad_version(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
state_file = tmp_path / "bad.json"
|
|
state_file.write_text(json.dumps({"version": 999}))
|
|
|
|
page = _make_mock_page()
|
|
browser = _make_mock_browser()
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
result = await mcp_state.skyvern_state_load(file_path="bad.json")
|
|
assert result["ok"] is False
|
|
assert "version" in result["error"]["message"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_load_malformed_json(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
state_file = tmp_path / "bad.json"
|
|
state_file.write_text("not json at all")
|
|
|
|
page = _make_mock_page()
|
|
browser = _make_mock_browser()
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
result = await mcp_state.skyvern_state_load(file_path="bad.json")
|
|
assert result["ok"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_load_filters_cross_domain_cookies(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Cookies from a different domain must not be applied."""
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
cookies = [
|
|
{"name": "c1", "value": "v1", "domain": ".other.com", "path": "/"},
|
|
{"name": "c2", "value": "v2", "domain": "another.org", "path": "/"},
|
|
]
|
|
state_file = tmp_path / "cross.json"
|
|
_write_state_file(state_file, cookies=cookies, url="https://other.com")
|
|
|
|
page = _make_mock_page("https://example.com")
|
|
browser = _make_mock_browser()
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
result = await mcp_state.skyvern_state_load(file_path="cross.json")
|
|
|
|
assert result["ok"] is True
|
|
assert result["data"]["cookie_count"] == 0
|
|
assert result["data"]["skipped_cookies"] == 2
|
|
browser._browser_context.add_cookies.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_state_save_load_roundtrip(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Save then load should produce consistent results."""
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
cookies = [{"name": "tok", "value": "123", "domain": "example.com", "path": "/"}]
|
|
ls = {"theme": "dark"}
|
|
ss = {"cart": "item1"}
|
|
|
|
page = _make_mock_page("https://example.com")
|
|
page.evaluate = AsyncMock(side_effect=[ls, ss])
|
|
|
|
browser = _make_mock_browser(cookies)
|
|
ctx = BrowserContext(mode="local")
|
|
_patch_get_page(monkeypatch, page, ctx)
|
|
_patch_session(monkeypatch, _make_session_state(browser))
|
|
|
|
save_result = await mcp_state.skyvern_state_save(file_path="roundtrip.json")
|
|
assert save_result["ok"] is True
|
|
|
|
page.evaluate = AsyncMock(return_value=None)
|
|
load_result = await mcp_state.skyvern_state_load(file_path="roundtrip.json")
|
|
assert load_result["ok"] is True
|
|
assert load_result["data"]["cookie_count"] == 1
|
|
assert load_result["data"]["local_storage_count"] == 1
|
|
assert load_result["data"]["session_storage_count"] == 1
|
|
assert load_result["data"]["skipped_cookies"] == 0
|