mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-26 10:41:14 +00:00
430 lines
16 KiB
Python
430 lines
16 KiB
Python
"""Tests for SQLite-first server mode.
|
|
|
|
Covers:
|
|
- _default_database_string() returns SQLite path under ~/.skyvern/
|
|
- Settings.is_sqlite() detection
|
|
- SQLite bootstrap in api_app lifespan (tables, org, token, idempotency)
|
|
- Settings snapshot/restore on bootstrap failure
|
|
- ForgeApp restoration on bootstrap failure
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from skyvern.config import _default_database_string
|
|
|
|
|
|
def test_default_database_string_is_sqlite() -> None:
|
|
"""_default_database_string returns a SQLite URL pointing at ~/.skyvern/data.db."""
|
|
result = _default_database_string()
|
|
assert result.startswith("sqlite+aiosqlite:///")
|
|
assert ".skyvern/data.db" in result
|
|
|
|
|
|
def test_default_database_string_is_pure(tmp_path: Path) -> None:
|
|
"""_default_database_string is a pure string computation — no side effects."""
|
|
fake_home = tmp_path / "fakehome"
|
|
with patch("skyvern.config.Path.home", return_value=fake_home):
|
|
result = _default_database_string()
|
|
assert not (fake_home / ".skyvern").exists(), "factory must not create directories"
|
|
assert ".skyvern/data.db" in result
|
|
|
|
|
|
def test_ensure_sqlite_dir_creates_directory(tmp_path: Path) -> None:
|
|
"""_ensure_sqlite_dir creates the parent directory for file-backed SQLite."""
|
|
from skyvern.config import _ensure_sqlite_dir
|
|
|
|
db_path = tmp_path / "subdir" / "data.db"
|
|
_ensure_sqlite_dir(f"sqlite+aiosqlite:///{db_path}")
|
|
assert (tmp_path / "subdir").is_dir()
|
|
|
|
|
|
def test_ensure_sqlite_dir_noop_for_memory() -> None:
|
|
"""_ensure_sqlite_dir is a no-op for in-memory SQLite."""
|
|
from skyvern.config import _ensure_sqlite_dir
|
|
|
|
_ensure_sqlite_dir("sqlite+aiosqlite:///:memory:") # should not raise
|
|
|
|
|
|
def test_ensure_sqlite_dir_noop_for_postgres() -> None:
|
|
"""_ensure_sqlite_dir is a no-op for Postgres URLs."""
|
|
from skyvern.config import _ensure_sqlite_dir
|
|
|
|
_ensure_sqlite_dir("postgresql+psycopg://localhost/test") # should not raise
|
|
|
|
|
|
def test_is_sqlite_true_for_sqlite_string() -> None:
|
|
"""is_sqlite() returns True when DATABASE_STRING starts with 'sqlite'."""
|
|
from skyvern.config import Settings
|
|
|
|
s = Settings(DATABASE_STRING="sqlite+aiosqlite:///test.db")
|
|
assert s.is_sqlite() is True
|
|
|
|
|
|
def test_is_sqlite_false_for_postgres_string() -> None:
|
|
"""is_sqlite() returns False for PostgreSQL strings."""
|
|
from skyvern.config import Settings
|
|
|
|
s = Settings(DATABASE_STRING="postgresql+psycopg://skyvern@localhost/skyvern")
|
|
assert s.is_sqlite() is False
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def sqlite_bootstrap_db(): # type: ignore[no-untyped-def] # pytest fixture
|
|
"""Swap in a disposable SQLite AgentDB for bootstrap tests."""
|
|
from skyvern.forge import app as forge_app
|
|
from skyvern.forge.sdk.db.agent_db import AgentDB
|
|
|
|
db = AgentDB("sqlite+aiosqlite:///:memory:")
|
|
original_db = forge_app.DATABASE
|
|
forge_app.DATABASE = db # type: ignore[assignment]
|
|
try:
|
|
yield db
|
|
finally:
|
|
forge_app.DATABASE = original_db # type: ignore[assignment]
|
|
await db.engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def patched_env_writes(): # type: ignore[no-untyped-def] # pytest fixture
|
|
"""Mock env-file writes so bootstrap tests do not touch the repo .env."""
|
|
with patch("skyvern.forge.sdk.services.local_org_auth_token_service._write_env") as write_env:
|
|
yield write_env
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_bootstrap_creates_tables_and_org(sqlite_bootstrap_db, patched_env_writes) -> None:
|
|
"""_bootstrap_sqlite creates tables, org, and API key in a SQLite DB."""
|
|
from skyvern.forge.api_app import _bootstrap_sqlite
|
|
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
|
|
from skyvern.forge.sdk.db.models import Base
|
|
from skyvern.forge.sdk.services.local_org_auth_token_service import SKYVERN_LOCAL_DOMAIN
|
|
|
|
async with sqlite_bootstrap_db.engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
await _bootstrap_sqlite()
|
|
|
|
org = await sqlite_bootstrap_db.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN)
|
|
assert org is not None
|
|
assert org.organization_name == "Skyvern-local"
|
|
token = await sqlite_bootstrap_db.get_valid_org_auth_token(org.organization_id, OrganizationAuthTokenType.api)
|
|
assert token is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_bootstrap_is_idempotent(sqlite_bootstrap_db, patched_env_writes) -> None:
|
|
"""Calling _bootstrap_sqlite twice does not create duplicate orgs."""
|
|
from skyvern.forge.api_app import _bootstrap_sqlite
|
|
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
|
|
from skyvern.forge.sdk.db.models import Base
|
|
from skyvern.forge.sdk.services.local_org_auth_token_service import SKYVERN_LOCAL_DOMAIN
|
|
|
|
async with sqlite_bootstrap_db.engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
await _bootstrap_sqlite()
|
|
org1 = await sqlite_bootstrap_db.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN)
|
|
|
|
# Second call should detect existing org and skip
|
|
await _bootstrap_sqlite()
|
|
org2 = await sqlite_bootstrap_db.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN)
|
|
|
|
assert org1 is not None
|
|
assert org2 is not None
|
|
assert org1.organization_id == org2.organization_id
|
|
token = await sqlite_bootstrap_db.get_valid_org_auth_token(org1.organization_id, OrganizationAuthTokenType.api)
|
|
assert token is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_bootstrap_from_empty_db(sqlite_bootstrap_db, patched_env_writes) -> None:
|
|
"""_bootstrap_sqlite creates tables AND org from a completely empty DB.
|
|
|
|
Unlike test_sqlite_bootstrap_creates_tables_and_org which pre-creates
|
|
tables, this test starts from scratch to cover the full first-start path.
|
|
"""
|
|
from skyvern.forge.api_app import _bootstrap_sqlite
|
|
from skyvern.forge.sdk.services.local_org_auth_token_service import SKYVERN_LOCAL_DOMAIN
|
|
|
|
# NO create_all — bootstrap must handle it
|
|
|
|
await _bootstrap_sqlite()
|
|
|
|
org = await sqlite_bootstrap_db.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN)
|
|
assert org is not None
|
|
assert org.organization_name == "Skyvern-local"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_bootstrap_syncs_existing_env_api_key(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
sqlite_bootstrap_db,
|
|
patched_env_writes,
|
|
) -> None:
|
|
"""An existing SKYVERN_API_KEY must become a valid token in a fresh SQLite DB."""
|
|
from skyvern.forge.api_app import _bootstrap_sqlite
|
|
from skyvern.forge.sdk.core.security import create_access_token
|
|
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
|
|
from skyvern.forge.sdk.services.local_org_auth_token_service import SKYVERN_LOCAL_DOMAIN
|
|
from skyvern.forge.sdk.services.org_auth_service import resolve_org_from_api_key
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
expected_org_id = "o_existing_local"
|
|
existing_api_key = create_access_token(expected_org_id)
|
|
monkeypatch.setenv("SKYVERN_API_KEY", existing_api_key)
|
|
# Bootstrap reads settings.SKYVERN_API_KEY (the pydantic singleton), not os.environ directly
|
|
monkeypatch.setattr("skyvern.config.settings.SKYVERN_API_KEY", existing_api_key)
|
|
|
|
await _bootstrap_sqlite()
|
|
|
|
org = await sqlite_bootstrap_db.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN)
|
|
assert org is not None
|
|
assert org.organization_id == expected_org_id
|
|
token = await sqlite_bootstrap_db.get_valid_org_auth_token(org.organization_id, OrganizationAuthTokenType.api)
|
|
assert token is not None
|
|
assert token.token == existing_api_key
|
|
validation = await resolve_org_from_api_key(existing_api_key, sqlite_bootstrap_db)
|
|
assert validation.organization.organization_id == expected_org_id
|
|
patched_env_writes.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_bootstrap_repairs_existing_org_without_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
sqlite_bootstrap_db,
|
|
patched_env_writes,
|
|
) -> None:
|
|
"""Bootstrap should self-heal an existing local org that has no API token."""
|
|
from skyvern.forge.api_app import _bootstrap_sqlite
|
|
from skyvern.forge.sdk.core.security import create_access_token
|
|
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
|
|
from skyvern.forge.sdk.db.models import Base
|
|
from skyvern.forge.sdk.services.local_org_auth_token_service import (
|
|
SKYVERN_LOCAL_DOMAIN,
|
|
ensure_local_org_with_id,
|
|
)
|
|
from skyvern.forge.sdk.services.org_auth_service import resolve_org_from_api_key
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
expected_org_id = "o_existing_local"
|
|
existing_api_key = create_access_token(expected_org_id)
|
|
monkeypatch.setenv("SKYVERN_API_KEY", existing_api_key)
|
|
monkeypatch.setattr("skyvern.config.settings.SKYVERN_API_KEY", existing_api_key)
|
|
|
|
async with sqlite_bootstrap_db.engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
org = await ensure_local_org_with_id(expected_org_id)
|
|
assert org.organization_id
|
|
assert (
|
|
await sqlite_bootstrap_db.get_valid_org_auth_token(org.organization_id, OrganizationAuthTokenType.api) is None
|
|
)
|
|
|
|
await _bootstrap_sqlite()
|
|
|
|
repaired_org = await sqlite_bootstrap_db.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN)
|
|
assert repaired_org is not None
|
|
token = await sqlite_bootstrap_db.get_valid_org_auth_token(
|
|
repaired_org.organization_id, OrganizationAuthTokenType.api
|
|
)
|
|
assert token is not None
|
|
assert token.token == existing_api_key
|
|
validation = await resolve_org_from_api_key(existing_api_key, sqlite_bootstrap_db)
|
|
assert validation.organization.organization_id == expected_org_id
|
|
patched_env_writes.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_bootstrap_regenerates_invalid_existing_env_api_key(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
sqlite_bootstrap_db,
|
|
patched_env_writes,
|
|
) -> None:
|
|
"""Bootstrap must replace an unusable env key with a valid local JWT."""
|
|
from skyvern.forge.api_app import _bootstrap_sqlite
|
|
from skyvern.forge.sdk.db.enums import OrganizationAuthTokenType
|
|
from skyvern.forge.sdk.services.local_org_auth_token_service import SKYVERN_LOCAL_DOMAIN
|
|
from skyvern.forge.sdk.services.org_auth_service import resolve_org_from_api_key
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("SKYVERN_API_KEY", "existing-test-key")
|
|
|
|
await _bootstrap_sqlite()
|
|
|
|
org = await sqlite_bootstrap_db.get_organization_by_domain(SKYVERN_LOCAL_DOMAIN)
|
|
assert org is not None
|
|
token = await sqlite_bootstrap_db.get_valid_org_auth_token(org.organization_id, OrganizationAuthTokenType.api)
|
|
assert token is not None
|
|
assert token.token != "existing-test-key"
|
|
validation = await resolve_org_from_api_key(token.token, sqlite_bootstrap_db)
|
|
assert validation.organization.organization_id == org.organization_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_allows_env_only_persistent_mode(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
"""Skyvern.local() should honor env-only persistent mode without requiring ./.env."""
|
|
from skyvern import Skyvern
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setenv("DATABASE_STRING", "postgresql+psycopg://skyvern@localhost/skyvern")
|
|
monkeypatch.setenv("SKYVERN_API_KEY", "dummy-key")
|
|
|
|
embedded_client = httpx.AsyncClient()
|
|
with patch(
|
|
"skyvern.library.embedded_server_factory.create_embedded_server", return_value=embedded_client
|
|
) as factory:
|
|
skyvern = Skyvern.local()
|
|
|
|
try:
|
|
assert factory.call_args.kwargs["use_in_memory_db"] is False
|
|
assert getattr(skyvern, "_embedded_client") is embedded_client
|
|
finally:
|
|
await skyvern.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_local_persistent_mode_accepts_settings_without_dotenv(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""Persistent mode should accept DATABASE_STRING and SKYVERN_API_KEY via settings overrides."""
|
|
from skyvern import Skyvern
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
embedded_client = httpx.AsyncClient()
|
|
overrides = {
|
|
"DATABASE_STRING": "postgresql+psycopg://skyvern@localhost/skyvern",
|
|
"SKYVERN_API_KEY": "dummy-key",
|
|
}
|
|
with patch(
|
|
"skyvern.library.embedded_server_factory.create_embedded_server", return_value=embedded_client
|
|
) as factory:
|
|
skyvern = Skyvern.local(use_in_memory_db=False, settings=overrides)
|
|
|
|
try:
|
|
assert factory.call_args.kwargs["use_in_memory_db"] is False
|
|
assert factory.call_args.kwargs["settings_overrides"] == overrides
|
|
assert getattr(skyvern, "_embedded_client") is embedded_client
|
|
finally:
|
|
await skyvern.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_embedded_server_uses_settings_api_key_in_persistent_mode() -> None:
|
|
"""Persistent embedded bootstrap should read SKYVERN_API_KEY from settings overrides."""
|
|
from skyvern.library.embedded_server_factory import create_embedded_server
|
|
|
|
seen_headers: dict[bytes, bytes] = {}
|
|
|
|
async def fake_app(scope, receive, send): # type: ignore[no-untyped-def]
|
|
nonlocal seen_headers
|
|
seen_headers = dict(scope["headers"])
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [(b"content-type", b"application/json")],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": b"[]"})
|
|
|
|
client = create_embedded_server(
|
|
settings_overrides={"SKYVERN_API_KEY": "dummy-key"},
|
|
use_in_memory_db=False,
|
|
)
|
|
try:
|
|
with patch("skyvern.library.embedded_server_factory.create_api_app", return_value=fake_app):
|
|
response = await client.get("/")
|
|
finally:
|
|
await client.aclose()
|
|
|
|
assert response.status_code == 200
|
|
assert seen_headers[b"x-api-key"] == b"dummy-key"
|
|
|
|
|
|
def test_settings_snapshot_restore_roundtrip() -> None:
|
|
"""Snapshot captures values and restore puts them back after mutation."""
|
|
from skyvern.config import settings
|
|
from skyvern.library.embedded_server_factory import _restore_settings, _snapshot_settings
|
|
|
|
original_db = settings.DATABASE_STRING
|
|
original_modules = settings.ADDITIONAL_MODULES[:]
|
|
|
|
snapshots = _snapshot_settings()
|
|
|
|
# Mutate settings
|
|
settings.DATABASE_STRING = "sqlite+aiosqlite:///:memory:"
|
|
settings.ADDITIONAL_MODULES = []
|
|
|
|
# Restore
|
|
_restore_settings(snapshots)
|
|
|
|
assert settings.DATABASE_STRING == original_db
|
|
assert settings.ADDITIONAL_MODULES == original_modules
|
|
|
|
|
|
def test_settings_snapshot_keys_cover_embedded_mutations() -> None:
|
|
"""Snapshot coverage should include both SQLite overrides and bootstrap-time mutations."""
|
|
from skyvern.library.embedded_server_factory import (
|
|
_BOOTSTRAP_RUNTIME_SETTINGS,
|
|
_SETTINGS_SNAPSHOT_KEYS,
|
|
_SQLITE_OVERRIDE_VALUES,
|
|
)
|
|
|
|
assert frozenset(_SQLITE_OVERRIDE_VALUES).issubset(_SETTINGS_SNAPSHOT_KEYS)
|
|
assert _BOOTSTRAP_RUNTIME_SETTINGS.issubset(_SETTINGS_SNAPSHOT_KEYS)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_failure_restores_settings() -> None:
|
|
"""If bootstrap fails, settings must be restored to pre-bootstrap values."""
|
|
from skyvern.config import settings
|
|
|
|
original_db = settings.DATABASE_STRING
|
|
|
|
from skyvern import Skyvern
|
|
|
|
# Create a client with a bad setting that will cause validation error
|
|
skyvern = Skyvern.local(
|
|
use_in_memory_db=True,
|
|
settings={"OTEL_ENABLED": True}, # Blocked setting
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Cannot override"):
|
|
await skyvern.get_workflows()
|
|
|
|
await skyvern.aclose()
|
|
|
|
# Settings should be restored to original values
|
|
assert settings.DATABASE_STRING == original_db
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bootstrap_failure_restores_forge_app() -> None:
|
|
"""If bootstrap fails, the forge app instance must be restored."""
|
|
from skyvern.forge import app as forge_app_holder
|
|
|
|
prev_inst = object.__getattribute__(forge_app_holder, "_inst")
|
|
|
|
from skyvern import Skyvern
|
|
|
|
skyvern = Skyvern.local(
|
|
use_in_memory_db=True,
|
|
settings={"OTEL_ENABLED": True}, # Blocked setting
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="Cannot override"):
|
|
await skyvern.get_workflows()
|
|
|
|
await skyvern.aclose()
|
|
|
|
current_inst = object.__getattribute__(forge_app_holder, "_inst")
|
|
assert current_inst is prev_inst
|