fix: Add plugin to load routes (#11759)

* add plugin to load routes

* Update src/backend/base/langflow/main.py

Co-authored-by: Gabriel Luiz Freitas Almeida <[email protected]>

* Update src/backend/base/langflow/main.py

Co-authored-by: Gabriel Luiz Freitas Almeida <[email protected]>

* avoid overwriting exisiting routes

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* Create tables needed for SSO

* update migration phase

* update sso path and fix ruff errors

* resolve review comments

* add more tests

* [autofix.ci] apply automated fixes

* fix ruff errors

* [autofix.ci] apply automated fixes

* fix ruff errors

* fix: reduce Node.js heap size to 4GB in Docker builds to prevent OOM

The Vite frontend build was configured with --max-old-space-size=12288
(12GB), which exceeds available RAM on ARM64 CI runners, causing the
build process to be OOM-killed during the transform phase.

Reduced to 4GB (4096MB) which is sufficient for the Vite build and
prevents OOM kills in memory-constrained Docker BuildKit environments.

* fix: avoid redundant recursive chown on /app in backend Dockerfile

The recursive chown -R on /app was re-owning the entire .venv (~2.6GB,
40k+ files) which was already correctly owned via COPY --chown=1000:0.
This was causing the build to be killed on ARM64 runners.

Changed to non-recursive chown on /app since only the directory itself
needs ownership set. /app/data still gets recursive chown (it's empty).

* fix: add Docker cleanup between image builds to prevent disk full

The 40GB ARM64 runner runs out of disk when building 3 Docker images
sequentially. Each image (main ~8GB layers, backend ~5GB, frontend)
accumulates build cache and layers that exhaust the disk.

Added cleanup steps between builds that:
- Remove the tested image (no longer needed)
- Prune all unused Docker data and buildx cache
- Log disk usage before/after for debugging

---------

Co-authored-by: Gabriel Luiz Freitas Almeida <[email protected]>
Co-authored-by: Hanzo Dev <[email protected]>
Co-authored-by: vijay kumar katuri <[email protected]>
This commit is contained in:
Himavarsha
2026-02-24 13:40:13 +00:00
committed by GitHub
co-authored by Gabriel Luiz Freitas Almeida Hanzo Dev vijay kumar katuri
parent 1b570d75da
commit b2a3162b60
10 changed files with 949 additions and 224 deletions
@@ -0,0 +1,90 @@
"""add SSO plugin tables sso_user_profile and sso_config
Revision ID: 2ef4ca4016a3
Revises: 369268b9af8b
Create Date: 2026-02-22 23:15:53.139035
Phase: EXPAND
"""
from collections.abc import Sequence
import sqlalchemy as sa
import sqlmodel
from alembic import op
from langflow.utils import migration
# revision identifiers, used by Alembic.
revision: str = "2ef4ca4016a3"
down_revision: str | None = "369268b9af8b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
if not migration.table_exists("sso_config", conn):
op.create_table(
"sso_config",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("provider", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("provider_name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("enforce_sso", sa.Boolean(), nullable=False),
sa.Column("client_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("client_secret_encrypted", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("discovery_url", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("redirect_uri", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("scopes", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("email_claim", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("username_claim", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("user_id_claim", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("token_endpoint", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("authorization_endpoint", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("jwks_uri", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("issuer", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
if not migration.table_exists("sso_user_profile", conn):
op.create_table(
"sso_user_profile",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("sso_provider", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("sso_user_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column("sso_last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("sso_user_profile", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_sso_user_profile_email"), ["email"], unique=False)
batch_op.create_index(
batch_op.f("uq_sso_user_profile_provider_user"),
["sso_provider", "sso_user_id"],
unique=True,
)
batch_op.create_index(batch_op.f("ix_sso_user_profile_user_id"), ["user_id"], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
conn = op.get_bind()
# ### commands auto generated by Alembic - please adjust! ###
if migration.table_exists("sso_user_profile", conn):
with op.batch_alter_table("sso_user_profile", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_sso_user_profile_user_id"))
batch_op.drop_index(batch_op.f("uq_sso_user_profile_provider_user"))
batch_op.drop_index(batch_op.f("ix_sso_user_profile_email"))
op.drop_table("sso_user_profile")
if migration.table_exists("sso_config", conn):
op.drop_table("sso_config")
# ### end Alembic commands ###
+7
View File
@@ -38,6 +38,7 @@ from langflow.initial_setup.setup import (
sync_flows_from_fs,
)
from langflow.middleware import ContentSizeLimitMiddleware
from langflow.plugin_routes import load_plugin_routes
from langflow.services.deps import (
get_queue_service,
get_service,
@@ -518,6 +519,12 @@ def create_app():
app.include_router(health_check_router)
app.include_router(log_router)
# --- plugin hook ---
# Discover and register additional routers provided by plugins
# via the ``langflow.plugins`` entry-point group.
# Each entry point must expose a callable ``register(app: FastAPI) -> None``.
load_plugin_routes(app)
@app.exception_handler(Exception)
async def exception_handler(_request: Request, exc: Exception):
if isinstance(exc, HTTPException):
+139
View File
@@ -0,0 +1,139 @@
"""Plugin route discovery and registration with conflict protection.
Plugins register via the ``langflow.plugins`` entry-point group. They receive
a wrapper so they cannot overwrite or shadow existing Langflow routes.
"""
from importlib.metadata import entry_points
from fastapi import FastAPI
from lfx.log.logger import logger
def _get_route_keys(app: FastAPI) -> set[tuple[str, str]]:
"""Collect (path, method) for all routes already on the app.
Used to build the reserved set before loading plugins so that plugin
routes cannot overwrite or shadow existing Langflow routes.
"""
keys: set[tuple[str, str]] = set()
for route in app.router.routes:
if hasattr(route, "path") and hasattr(route, "methods"):
for method in route.methods:
if method != "HEAD": # often same as GET
keys.add((route.path, method))
elif hasattr(route, "path") and hasattr(route, "path_regex"):
# Mount or similar: reserve path for all methods
keys.add((route.path, "*"))
return keys
class _PluginAppWrapper:
"""Wrapper around the real FastAPI app that only allows adding routes.
- Rejects adding a route if (path, method) is already reserved (no shadowing).
- Does not expose router/routes so plugins cannot remove or reorder routes.
"""
def __init__(self, app: FastAPI, reserved: set[tuple[str, str]]) -> None:
self._app = app
self._reserved = set(reserved)
def _check_and_reserve(self, path: str, methods: set[str]) -> None:
for method in methods:
if method == "HEAD":
continue
key = (path, method)
if key in self._reserved:
msg = f"Plugin route conflicts with existing route: {path} [{method}]"
raise ValueError(msg)
self._reserved.add(key)
def include_router(self, router, prefix: str = "", **kwargs) -> None:
# Effective prefix: include_router(prefix=) + router's own prefix (e.g. APIRouter(prefix="/sso"))
router_prefix = getattr(router, "prefix", "") or ""
base = (prefix or "") + router_prefix
for route in router.routes:
if hasattr(route, "path") and hasattr(route, "methods"):
full_path = base + route.path
self._check_and_reserve(full_path, set(route.methods))
elif hasattr(route, "path"):
full_path = base + route.path
self._check_and_reserve(full_path, {"*"})
self._app.include_router(router, prefix=prefix, **kwargs)
def get(self, path: str, **kwargs):
self._check_and_reserve(path, {"GET"})
return self._app.get(path, **kwargs)
def post(self, path: str, **kwargs):
self._check_and_reserve(path, {"POST"})
return self._app.post(path, **kwargs)
def put(self, path: str, **kwargs):
self._check_and_reserve(path, {"PUT"})
return self._app.put(path, **kwargs)
def delete(self, path: str, **kwargs):
self._check_and_reserve(path, {"DELETE"})
return self._app.delete(path, **kwargs)
def patch(self, path: str, **kwargs):
self._check_and_reserve(path, {"PATCH"})
return self._app.patch(path, **kwargs)
def on_event(self, event_type: str):
return self._app.on_event(event_type)
# Expose other delegates plugins might need (no route mutation)
@property
def openapi(self):
return self._app.openapi
def add_api_route(self, path: str, endpoint, **kwargs):
methods = kwargs.get("methods", ["GET"])
self._check_and_reserve(path, set(methods))
return self._app.add_api_route(path, endpoint, **kwargs)
def load_plugin_routes(app: FastAPI) -> None:
"""Discover and register additional routers from enterprise plugins.
Plugins register themselves via the ``langflow.plugins`` entry-point group.
Each entry point must expose a callable with the signature::
def register(app: FastAPI) -> None: ...
"""
reserved = _get_route_keys(app)
wrapper = _PluginAppWrapper(app, reserved)
eps = entry_points(group="langflow.plugins")
for ep in sorted(eps, key=lambda e: e.name):
try:
plugin_register = ep.load()
except Exception: # noqa: BLE001
logger.error(
"Failed to load plugin entry point '%s' (broken import or missing dependency)",
ep.name,
exc_info=True,
)
continue
try:
plugin_register(wrapper)
logger.info(f"Loaded plugin: {ep.name}")
except ValueError as e:
logger.warning(
"Plugin '%s' rejected (route conflict): %s",
ep.name,
e,
exc_info=True,
)
except Exception: # noqa: BLE001
logger.error(
"Plugin '%s' failed during registration",
ep.name,
exc_info=True,
)
@@ -1,4 +1,5 @@
from .api_key import ApiKey
from .auth import SSOConfig, SSOUserProfile
from .file import File
from .flow import Flow
from .folder import Folder
@@ -15,6 +16,8 @@ __all__ = [
"Folder",
"Job",
"MessageTable",
"SSOConfig",
"SSOUserProfile",
"TransactionTable",
"User",
"Variable",
@@ -0,0 +1,6 @@
from .sso import SSOConfig, SSOUserProfile
__all__ = [
"SSOConfig",
"SSOUserProfile",
]
@@ -0,0 +1,76 @@
"""SSO plugin tables.
These tables are used by the SSO plugin for identity and provider configuration.
Migrations are managed by Langflow (OSS); the plugin must not create or
migrate these tables.
Plugins must use these tables via the models exported from
``langflow.services.database.models`` (e.g. ``SSOUserProfile``, ``SSOConfig``).
"""
from datetime import datetime, timezone
from uuid import uuid4
import sqlalchemy as sa
from sqlalchemy import Column, ForeignKey, UniqueConstraint
from sqlmodel import Field, SQLModel
from langflow.schema.serialize import UUIDstr
class SSOUserProfile(SQLModel, table=True): # type: ignore[call-arg]
"""SSO profile per user. Used by the SSO plugin for JIT provisioning and login."""
__tablename__ = "sso_user_profile"
__table_args__ = (UniqueConstraint("sso_provider", "sso_user_id", name="uq_sso_user_profile_provider_user"),)
id: UUIDstr = Field(default_factory=uuid4, primary_key=True)
user_id: UUIDstr = Field(
sa_column=Column(
sa.Uuid(),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
)
sso_provider: str = Field()
sso_user_id: str = Field()
email: str | None = Field(default=None, index=True)
sso_last_login_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class SSOConfig(SQLModel, table=True): # type: ignore[call-arg]
"""SSO provider configuration (persisted in DB). Used by the SSO plugin."""
__tablename__ = "sso_config"
id: UUIDstr = Field(default_factory=uuid4, primary_key=True)
provider: str = Field(description="oidc, saml, ldap")
provider_name: str = Field()
enabled: bool = Field(default=True)
enforce_sso: bool = Field(default=False)
client_id: str | None = Field(default=None)
client_secret_encrypted: str | None = Field(default=None)
discovery_url: str | None = Field(default=None)
redirect_uri: str | None = Field(default=None)
scopes: str | None = Field(default="openid email profile")
email_claim: str = Field(default="email")
username_claim: str = Field(default="preferred_username")
user_id_claim: str = Field(default="sub")
token_endpoint: str | None = Field(default=None)
authorization_endpoint: str | None = Field(default=None)
jwks_uri: str | None = Field(default=None)
issuer: str | None = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_by: UUIDstr | None = Field(
default=None,
sa_column=Column(
sa.Uuid(),
ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
),
)
@@ -1,142 +1,144 @@
"""Integration tests for database service with Windows + PostgreSQL fix.
"""Integration tests for database service with Windows + PostgreSQL event loop.
Tests that the database service properly handles event loop configuration
across different platforms and database types.
Uses real settings objects and real SQLite engine where possible; only
platform.system is patched to simulate Windows vs non-Windows. Project
convention: avoid mocking in tests.
"""
import asyncio
import os
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from langflow.services.database.service import DatabaseService
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
class _TestSettings:
"""Minimal settings-like object (no mocks) for DatabaseService init."""
database_url = "sqlite+aiosqlite:///:memory:"
database_connection_retry = False
sqlite_pragmas = {}
db_driver_connection_settings = None
db_connection_settings = {}
db_connect_timeout = 5
alembic_log_to_stdout = True
alembic_log_file = "alembic.log"
use_noop_database = False
model_fields_set = set()
class _TestAuthSettings:
AUTO_LOGIN = False
SUPERUSER = "admin"
class _TestSettingsService:
"""Minimal settings service (no mocks) for DatabaseService."""
settings = _TestSettings()
auth_settings = _TestAuthSettings()
@pytest.fixture
def test_settings_service():
"""Real settings-like object with required attributes (no MagicMock)."""
return _TestSettingsService()
class TestDatabaseServiceWindowsPostgres:
"""Test database service with Windows + PostgreSQL event loop configuration."""
"""Database service with Windows + PostgreSQL event loop configuration."""
@pytest.fixture
def mock_settings_service(self):
"""Create a mock settings service for testing."""
mock_service = MagicMock()
mock_service.settings.database_url = "sqlite:///test.db"
mock_service.settings.database_connection_retry = False
mock_service.settings.sqlite_pragmas = {}
mock_service.settings.db_driver_connection_settings = None
mock_service.settings.db_connection_settings = {}
mock_service.settings.alembic_log_to_stdout = True
mock_service.settings.alembic_log_file = "alembic.log"
return mock_service
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "postgresql://user:pass@localhost/db"}, clear=True)
@patch("langflow.services.database.service.create_async_engine")
@patch("langflow.services.database.service.configure_windows_postgres_event_loop")
def test_windows_postgresql_configures_event_loop(
self, mock_configure, mock_create_engine, mock_platform, mock_settings_service
):
"""Test that Windows + PostgreSQL configures the event loop correctly."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_postgresql_calls_configure_with_source(self, mock_platform, test_settings_service):
"""Windows + PostgreSQL: configure_windows_postgres_event_loop is called with source."""
mock_platform.return_value = "Windows"
mock_settings_service.settings.database_url = "postgresql://user:pass@localhost/db"
mock_create_engine.return_value = MagicMock()
mock_configure.return_value = True
test_settings_service.settings.database_url = "postgresql://user:pass@localhost/db"
with patch("langflow.services.database.service.create_async_engine") as create_engine:
create_engine.return_value = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
_ = DatabaseService(test_settings_service)
create_engine.assert_called_once()
mock_platform.assert_called()
_ = DatabaseService(mock_settings_service)
mock_configure.assert_called_once_with(source="database_service")
@patch("platform.system")
@patch.dict(os.environ, {}, clear=True)
@patch("langflow.services.database.service.create_async_engine")
def test_linux_postgresql_no_event_loop_change(self, mock_create_engine, mock_platform, mock_settings_service):
"""Test that Linux + PostgreSQL doesn't change event loop."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_linux_sqlite_real_engine_no_event_loop_change(self, mock_platform, test_settings_service):
"""Linux + SQLite: real engine creation, event loop unchanged (no mock of create_async_engine)."""
mock_platform.return_value = "Linux"
mock_settings_service.settings.database_url = "postgresql://user:pass@localhost/db"
mock_create_engine.return_value = MagicMock()
test_settings_service.settings.database_url = "sqlite+aiosqlite://"
original_policy = asyncio.get_event_loop_policy()
_ = DatabaseService(mock_settings_service)
# Policy should remain unchanged
service = DatabaseService(test_settings_service)
assert asyncio.get_event_loop_policy() is original_policy
assert service.async_session_maker is not None
assert service.database_url == "sqlite+aiosqlite://"
@patch("platform.system")
@patch.dict(os.environ, {}, clear=True)
@patch("langflow.services.database.service.create_async_engine")
def test_macos_postgresql_no_event_loop_change(self, mock_create_engine, mock_platform, mock_settings_service):
"""Test that macOS + PostgreSQL doesn't change event loop."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_macos_sqlite_real_engine_no_event_loop_change(self, mock_platform, test_settings_service):
"""MacOS + SQLite: real engine, event loop unchanged."""
mock_platform.return_value = "Darwin"
mock_settings_service.settings.database_url = "postgresql://user:pass@localhost/db"
mock_create_engine.return_value = MagicMock()
test_settings_service.settings.database_url = "sqlite+aiosqlite://"
original_policy = asyncio.get_event_loop_policy()
_ = DatabaseService(mock_settings_service)
# Policy should remain unchanged
service = DatabaseService(test_settings_service)
assert asyncio.get_event_loop_policy() is original_policy
assert service.async_session_maker is not None
@patch("platform.system")
@patch.dict(os.environ, {}, clear=True)
@patch("langflow.services.database.service.create_async_engine")
@patch("langflow.services.database.service.configure_windows_postgres_event_loop")
def test_windows_sqlite_no_event_loop_change(
self, mock_configure, mock_create_engine, mock_platform, mock_settings_service
):
"""Test that Windows + SQLite doesn't change event loop."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_sqlite_real_engine_configure_called(self, mock_platform, test_settings_service):
"""Windows + SQLite: real engine; configure is still called (returns False)."""
mock_platform.return_value = "Windows"
mock_settings_service.settings.database_url = "sqlite:///test.db"
mock_create_engine.return_value = MagicMock()
mock_configure.return_value = False
test_settings_service.settings.database_url = "sqlite+aiosqlite://"
original_policy = asyncio.get_event_loop_policy()
_ = DatabaseService(mock_settings_service)
mock_configure.assert_called_once_with(source="database_service")
service = DatabaseService(test_settings_service)
def test_database_url_sanitization(self, mock_settings_service):
"""Test that database URLs are properly sanitized."""
assert asyncio.get_event_loop_policy() is original_policy
assert service.async_session_maker is not None
def test_database_url_sanitization_real_settings(self, test_settings_service):
"""Database URLs are sanitized (real settings, only create_engine patched for speed)."""
test_cases = [
("sqlite:///test.db", "sqlite+aiosqlite:///test.db"),
("postgresql://user:pass@localhost/db", "postgresql+psycopg://user:pass@localhost/db"),
("postgres://user:pass@localhost/db", "postgresql+psycopg://user:pass@localhost/db"),
]
with patch("langflow.services.database.service.create_async_engine") as mock_create_engine:
mock_create_engine.return_value = MagicMock()
with patch("langflow.services.database.service.create_async_engine") as create_engine:
create_engine.return_value = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
for input_url, expected_url in test_cases:
mock_settings_service.settings.database_url = input_url
service = DatabaseService(mock_settings_service)
test_settings_service.settings.database_url = input_url
service = DatabaseService(test_settings_service)
assert service.database_url == expected_url
@patch("platform.system")
def test_docker_environment_compatibility(self, mock_platform, mock_settings_service):
"""Test that Docker environments work correctly."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_async_session_maker_available_after_init(self, mock_platform, test_settings_service):
"""After init with SQLite, async_session_maker is usable (real engine)."""
mock_platform.return_value = "Linux"
os.environ["DOCKER_CONTAINER"] = "true"
mock_settings_service.settings.database_url = "postgresql://postgres:5432/langflow"
test_settings_service.settings.database_url = "sqlite+aiosqlite://"
with patch("langflow.services.database.service.create_async_engine") as mock_create_engine:
mock_create_engine.return_value = MagicMock()
service = DatabaseService(test_settings_service)
# Should not raise any errors
service = DatabaseService(mock_settings_service)
assert service.database_url == "postgresql+psycopg://postgres:5432/langflow"
assert service.async_session_maker is not None
@pytest.mark.asyncio
async def test_async_operations_work_after_configuration(self, mock_settings_service):
"""Test that async operations work correctly after event loop configuration."""
mock_settings_service.settings.database_url = "sqlite:///test.db"
async def test_async_operations_work_after_init(self, test_settings_service):
"""Async session maker works after init (real engine, no mock)."""
with patch("langflow.helpers.windows_postgres_helper.platform.system") as mock_platform:
mock_platform.return_value = "Linux"
service = DatabaseService(test_settings_service)
with patch("langflow.services.database.service.create_async_engine") as mock_create_engine:
mock_engine = MagicMock()
mock_create_engine.return_value = mock_engine
service = DatabaseService(mock_settings_service)
# Test that async session maker is properly configured
assert service.async_session_maker is not None
# Simulate an async operation
async def test_async():
return True
result = await test_async()
assert result is True
async with service.async_session_maker() as session:
assert session is not None
await session.execute(text("SELECT 1"))
@@ -0,0 +1,248 @@
"""Tests for plugin route discovery and conflict protection.
Ensures that plugins loaded via the langflow.plugins entry-point group
cannot overwrite or shadow existing Langflow routes.
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi import FastAPI
from fastapi.routing import APIRouter
from langflow.plugin_routes import (
_get_route_keys,
_PluginAppWrapper,
load_plugin_routes,
)
class TestGetRouteKeys:
"""Tests for _get_route_keys."""
def test_returns_default_routes_for_app_with_no_custom_routes(self):
"""App with no custom routes still has FastAPI default OpenAPI routes in the set."""
app = FastAPI()
keys = _get_route_keys(app)
# FastAPI adds /docs, /openapi.json, /redoc, etc. by default
assert ("/openapi.json", "GET") in keys
assert ("/docs", "GET") in keys
def test_collects_route_path_and_methods(self):
"""All (path, method) pairs from app routes are collected."""
app = FastAPI()
@app.get("/health")
def health():
return "ok"
@app.post("/api/action")
def action():
return None
keys = _get_route_keys(app)
assert ("/health", "GET") in keys
assert ("/api/action", "POST") in keys
# HEAD is excluded to avoid false conflicts with GET
assert ("/health", "HEAD") not in keys
def test_includes_mounts_as_wildcard(self):
"""Mounts are reserved as (path, '*')."""
app = FastAPI()
app.mount("/static", MagicMock())
keys = _get_route_keys(app)
assert any(path == "/static" and method == "*" for path, method in keys)
class TestPluginAppWrapper:
"""Tests for _PluginAppWrapper: add-only, no overwrite."""
def test_allows_non_conflicting_route(self):
"""Wrapper allows adding a route that does not conflict with reserved set."""
app = FastAPI()
@app.get("/existing")
def existing():
return "core"
reserved = _get_route_keys(app)
wrapper = _PluginAppWrapper(app, reserved)
@wrapper.get("/plugin-only")
def plugin_only():
return "plugin"
# Real app should have both routes
keys_after = _get_route_keys(app)
assert ("/existing", "GET") in keys_after
assert ("/plugin-only", "GET") in keys_after
def test_raises_on_conflicting_get(self):
"""Adding a route with same (path, method) as reserved raises ValueError."""
app = FastAPI()
@app.get("/api/login")
def core_login():
return "core"
reserved = _get_route_keys(app)
wrapper = _PluginAppWrapper(app, reserved)
with pytest.raises(ValueError, match="Plugin route conflicts with existing route: /api/login \\[GET\\]"):
wrapper.get("/api/login")(lambda: "plugin")
def test_raises_on_conflicting_include_router(self):
"""include_router with a route that conflicts with reserved raises ValueError."""
app = FastAPI()
@app.get("/api/v1/flow")
def core_flow():
return "core"
reserved = _get_route_keys(app)
wrapper = _PluginAppWrapper(app, reserved)
router = APIRouter()
@router.get("/flow")
def plugin_flow():
return "plugin"
with pytest.raises(ValueError, match="Plugin route conflicts with existing route"):
wrapper.include_router(router, prefix="/api/v1")
def test_include_router_allows_non_conflicting_prefix(self):
"""include_router with distinct prefix succeeds and reserves new paths."""
app = FastAPI()
@app.get("/health")
def health():
return "ok"
reserved = _get_route_keys(app)
wrapper = _PluginAppWrapper(app, reserved)
router = APIRouter(prefix="/sso")
@router.get("/login")
def sso_login():
return "sso"
wrapper.include_router(router, prefix="/api/v1")
keys_after = _get_route_keys(app)
assert ("/api/v1/sso/login", "GET") in keys_after
def test_on_event_delegates_without_conflict_check(self):
"""on_event is delegated to the real app and does not check route conflicts."""
app = FastAPI()
reserved = _get_route_keys(app)
wrapper = _PluginAppWrapper(app, reserved)
called = []
@wrapper.on_event("startup")
def on_startup():
called.append(True)
# Trigger lifespan startup to ensure handler is registered
assert hasattr(wrapper._app, "router")
def test_second_plugin_cannot_overwrite_first_plugin_route(self):
"""Reserved set is cumulative; second plugin cannot register same path."""
app = FastAPI()
reserved = _get_route_keys(app)
wrapper = _PluginAppWrapper(app, reserved)
# First "plugin" adds /api/v1/sso/login
wrapper.get("/api/v1/sso/login")(lambda: "first")
# Second plugin trying same path should fail
with pytest.raises(ValueError, match=r"conflicts with existing route.*/api/v1/sso/login"):
wrapper.get("/api/v1/sso/login")(lambda: "second")
class TestLoadPluginRoutes:
"""Tests for load_plugin_routes with mocked entry_points."""
def test_no_crash_when_no_plugins(self):
"""When there are no entry points, load_plugin_routes does not crash."""
app = FastAPI()
@app.get("/health")
def health():
return "ok"
with patch("langflow.plugin_routes.entry_points", return_value=[]):
load_plugin_routes(app)
keys = _get_route_keys(app)
assert ("/health", "GET") in keys
def test_plugin_that_registers_route_is_loaded(self):
"""A plugin that registers a non-conflicting route is loaded successfully."""
app = FastAPI()
@app.get("/health")
def health():
return "ok"
def register(app_like):
@app_like.get("/api/v1/sso/login")
def login():
return "sso"
ep = MagicMock()
ep.name = "enterprise"
ep.load.return_value = register
with patch("langflow.plugin_routes.entry_points", return_value=[ep]):
load_plugin_routes(app)
keys = _get_route_keys(app)
assert ("/api/v1/sso/login", "GET") in keys
def test_plugin_with_conflict_is_skipped_app_continues(self):
"""When a plugin tries to register a conflicting route, that plugin is skipped."""
app = FastAPI()
@app.get("/api/v1/flow")
def core_flow():
return "core"
def conflicting_register(app_like):
app_like.get("/api/v1/flow")(lambda: "plugin")
ep = MagicMock()
ep.name = "bad_plugin"
ep.load.return_value = conflicting_register
with patch("langflow.plugin_routes.entry_points", return_value=[ep]):
load_plugin_routes(app)
# Core route must still be the only one at that path
routes_at_path = [
r for r in app.router.routes if getattr(r, "path", None) == "/api/v1/flow" and hasattr(r, "methods")
]
assert len(routes_at_path) == 1
def test_plugin_that_raises_is_skipped_app_continues(self):
"""When a plugin raises a non-ValueError exception, it is skipped."""
app = FastAPI()
@app.get("/health")
def health():
return "ok"
def broken_register(_app_like):
err_msg = "plugin broken"
raise RuntimeError(err_msg)
ep = MagicMock()
ep.name = "broken_plugin"
ep.load.return_value = broken_register
with patch("langflow.plugin_routes.entry_points", return_value=[ep]):
load_plugin_routes(app)
# App still has core route
keys = _get_route_keys(app)
assert ("/health", "GET") in keys
+187
View File
@@ -0,0 +1,187 @@
"""Tests for SSO plugin models against a real database.
No mocks: uses in-memory SQLite with foreign keys enabled to verify
CASCADE delete, unique constraints, and default values.
"""
import pytest
from langflow.services.database.models.auth.sso import SSOConfig, SSOUserProfile
from langflow.services.database.models.user.model import User
from sqlalchemy import event
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
# Placeholder for User.password in tests (not a real secret)
_TEST_PASSWORD = "hashed" # noqa: S105
@pytest.fixture(name="sso_db_engine")
def sso_db_engine():
"""Async engine with SQLite and foreign keys enabled (real DB, no mocks)."""
engine = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record): # noqa: ARG001
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return engine
@pytest.fixture(name="sso_async_session")
async def sso_async_session(sso_db_engine):
"""Async session with SSO and User tables created (real DB)."""
async with sso_db_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async with AsyncSession(sso_db_engine, expire_on_commit=False) as session:
yield session
async with sso_db_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
await sso_db_engine.dispose()
@pytest.mark.asyncio
class TestSSOUserProfile:
"""SSOUserProfile model tests against real database."""
async def test_create_and_read_sso_user_profile(self, sso_async_session):
"""Create and read SSOUserProfile records."""
user = User(username="sso_user", password=_TEST_PASSWORD)
sso_async_session.add(user)
await sso_async_session.commit()
await sso_async_session.refresh(user)
profile = SSOUserProfile(
user_id=user.id,
sso_provider="oidc",
sso_user_id="sub-123",
email="[email protected]",
)
sso_async_session.add(profile)
await sso_async_session.commit()
await sso_async_session.refresh(profile)
assert profile.id is not None
assert profile.user_id == user.id
assert profile.sso_provider == "oidc"
assert profile.sso_user_id == "sub-123"
assert profile.email == "[email protected]"
assert profile.created_at is not None
assert profile.updated_at is not None
async def test_user_id_unique_constraint(self, sso_async_session):
"""Cannot create two SSO profiles for the same user."""
user = User(username="unique_user", password=_TEST_PASSWORD)
sso_async_session.add(user)
await sso_async_session.commit()
await sso_async_session.refresh(user)
sso_async_session.add(SSOUserProfile(user_id=user.id, sso_provider="oidc", sso_user_id="sub-1"))
await sso_async_session.commit()
duplicate = SSOUserProfile(user_id=user.id, sso_provider="saml", sso_user_id="sub-2")
sso_async_session.add(duplicate)
with pytest.raises(IntegrityError, match=r"UNIQUE constraint failed|unique constraint"):
await sso_async_session.commit()
async def test_composite_unique_sso_provider_sso_user_id(self, sso_async_session):
"""Same (sso_provider, sso_user_id) cannot be used for two different users."""
user1 = User(username="user1", password=_TEST_PASSWORD)
user2 = User(username="user2", password=_TEST_PASSWORD)
sso_async_session.add(user1)
sso_async_session.add(user2)
await sso_async_session.commit()
await sso_async_session.refresh(user1)
await sso_async_session.refresh(user2)
sso_async_session.add(SSOUserProfile(user_id=user1.id, sso_provider="oidc", sso_user_id="sub-123"))
await sso_async_session.commit()
duplicate = SSOUserProfile(user_id=user2.id, sso_provider="oidc", sso_user_id="sub-123")
sso_async_session.add(duplicate)
with pytest.raises(IntegrityError, match=r"UNIQUE constraint failed|unique constraint"):
await sso_async_session.commit()
async def test_cascade_delete_when_user_deleted(self, sso_async_session):
"""Deleting user deletes associated SSOUserProfile (CASCADE)."""
user = User(username="cascade_user", password=_TEST_PASSWORD)
sso_async_session.add(user)
await sso_async_session.commit()
await sso_async_session.refresh(user)
profile = SSOUserProfile(user_id=user.id, sso_provider="oidc", sso_user_id="sub-cascade")
sso_async_session.add(profile)
await sso_async_session.commit()
await sso_async_session.refresh(profile)
profile_id = profile.id
await sso_async_session.delete(user)
await sso_async_session.commit()
result = await sso_async_session.exec(select(SSOUserProfile).where(SSOUserProfile.id == profile_id))
assert result.first() is None
async def test_default_timestamps_set(self, sso_async_session):
"""created_at and updated_at are set on create."""
user = User(username="ts_user", password=_TEST_PASSWORD)
sso_async_session.add(user)
await sso_async_session.commit()
await sso_async_session.refresh(user)
profile = SSOUserProfile(user_id=user.id, sso_provider="oidc", sso_user_id="sub-ts")
sso_async_session.add(profile)
await sso_async_session.commit()
await sso_async_session.refresh(profile)
assert profile.created_at is not None
assert profile.updated_at is not None
@pytest.mark.asyncio
class TestSSOConfig:
"""SSOConfig model tests against real database."""
async def test_create_and_read_sso_config(self, sso_async_session):
"""Create and read SSOConfig."""
config = SSOConfig(
provider="oidc",
provider_name="Test OIDC",
)
sso_async_session.add(config)
await sso_async_session.commit()
await sso_async_session.refresh(config)
assert config.id is not None
assert config.provider == "oidc"
assert config.provider_name == "Test OIDC"
assert config.enabled is True
assert config.enforce_sso is False
assert config.scopes == "openid email profile"
assert config.email_claim == "email"
assert config.username_claim == "preferred_username"
assert config.user_id_claim == "sub"
assert config.created_at is not None
assert config.updated_at is not None
async def test_default_values(self, sso_async_session):
"""Default values are applied when not specified."""
config = SSOConfig(provider="oidc", provider_name="Default Test")
sso_async_session.add(config)
await sso_async_session.commit()
await sso_async_session.refresh(config)
assert config.enabled is True
assert config.enforce_sso is False
assert config.scopes == "openid email profile"
assert config.email_claim == "email"
assert config.username_claim == "preferred_username"
assert config.user_id_claim == "sub"
assert config.created_by is None
@@ -1,16 +1,15 @@
"""Unit tests for Windows + PostgreSQL event loop configuration.
These tests ensure the fix works correctly across all platforms:
- Windows with PostgreSQL (applies fix)
- Windows with SQLite (no fix)
- Linux (no fix)
- macOS (no fix)
- Docker (no fix)
Tests use minimal mocking: only platform.system (and env) where needed to
simulate Windows on non-Windows CI. asyncio policy get/set are exercised
for real. Project convention: avoid mocking in tests.
"""
import asyncio
import logging
import os
from unittest.mock import MagicMock, patch
import platform
from unittest.mock import patch
import pytest
from langflow.helpers.windows_postgres_helper import (
@@ -19,8 +18,25 @@ from langflow.helpers.windows_postgres_helper import (
configure_windows_postgres_event_loop,
)
# Mock class for asyncio.WindowsSelectorEventLoopPolicy (only exists on Windows)
MockWindowsSelectorPolicy = type("WindowsSelectorEventLoopPolicy", (), {})
# Subclass required so set_event_loop_policy() accepts our instance (real asyncio API).
class MockWindowsSelectorPolicy(asyncio.AbstractEventLoopPolicy):
"""Minimal policy for tests when WindowsSelectorEventLoopPolicy is not available."""
def get_event_loop(self):
return asyncio.get_event_loop()
def set_event_loop(self, loop):
pass
def new_event_loop(self):
return asyncio.new_event_loop()
def get_child_watcher(self):
return None
def set_child_watcher(self, watcher):
pass
class TestWindowsPostgresHelper:
@@ -34,137 +50,94 @@ class TestWindowsPostgresHelper:
asyncio.set_event_loop_policy(original_policy)
def test_constants_defined(self):
"""Test that required constants are properly defined."""
"""Required constants are defined."""
assert LANGFLOW_DATABASE_URL == "LANGFLOW_DATABASE_URL"
assert POSTGRESQL_PREFIXES == ("postgresql", "postgres")
@patch("platform.system")
@patch.dict(os.environ, {}, clear=True)
def test_non_windows_returns_false(self, mock_platform):
"""Test that non-Windows systems return False and don't change event loop."""
for system in ["Linux", "Darwin", "FreeBSD"]:
mock_platform.return_value = system
os.environ[LANGFLOW_DATABASE_URL] = "postgresql://user:pass@localhost/db"
@pytest.mark.skipif(platform.system() == "Windows", reason="Only run on non-Windows")
def test_on_non_windows_with_postgres_url_returns_false_no_mock(self):
"""On non-Windows, function returns False without mocking platform."""
with patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "postgresql://user:pass@localhost/db"}, clear=True):
result = configure_windows_postgres_event_loop()
assert result is False
assert result is False
@patch("platform.system")
@patch.dict(os.environ, {}, clear=True)
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_without_database_url_returns_false(self, mock_platform):
"""Test Windows without DATABASE_URL returns False."""
"""Windows without DATABASE_URL returns False."""
mock_platform.return_value = "Windows"
result = configure_windows_postgres_event_loop()
with patch.dict(os.environ, {}, clear=True):
result = configure_windows_postgres_event_loop()
assert result is False
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "sqlite:///test.db"}, clear=True)
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_with_sqlite_returns_false(self, mock_platform):
"""Test Windows with SQLite returns False and doesn't change event loop."""
"""Windows with SQLite URL returns False."""
mock_platform.return_value = "Windows"
result = configure_windows_postgres_event_loop()
with patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "sqlite:///test.db"}, clear=True):
result = configure_windows_postgres_event_loop()
assert result is False
@patch.object(asyncio, "WindowsSelectorEventLoopPolicy", MockWindowsSelectorPolicy, create=True)
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "postgresql://user:pass@localhost/db"}, clear=True)
@patch("asyncio.get_event_loop_policy")
@patch("asyncio.set_event_loop_policy")
def test_windows_with_postgresql_sets_policy(self, mock_set_policy, mock_get_policy, mock_platform):
"""Test Windows with PostgreSQL sets WindowsSelectorEventLoopPolicy."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_with_postgresql_sets_policy(self, mock_platform):
"""Windows with PostgreSQL URL sets event loop policy (real get/set_event_loop_policy)."""
mock_platform.return_value = "Windows"
mock_policy = MagicMock()
mock_get_policy.return_value = mock_policy
result = configure_windows_postgres_event_loop()
with patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "postgresql://user:pass@localhost/db"}, clear=True):
result = configure_windows_postgres_event_loop()
assert result is True
mock_set_policy.assert_called_once()
args = mock_set_policy.call_args[0]
assert len(args) == 1
assert "WindowsSelectorEventLoopPolicy" in str(args[0].__class__)
assert isinstance(asyncio.get_event_loop_policy(), MockWindowsSelectorPolicy)
@patch.object(asyncio, "WindowsSelectorEventLoopPolicy", MockWindowsSelectorPolicy, create=True)
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "postgres://user:pass@localhost/db"}, clear=True)
@patch("asyncio.get_event_loop_policy")
@patch("asyncio.set_event_loop_policy")
def test_windows_with_postgres_protocol_sets_policy(self, mock_set_policy, mock_get_policy, mock_platform):
"""Test Windows with 'postgres://' (deprecated) protocol also works."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_with_postgres_protocol_sets_policy(self, mock_platform):
"""Windows with postgres:// URL sets policy."""
mock_platform.return_value = "Windows"
mock_policy = MagicMock()
mock_get_policy.return_value = mock_policy
result = configure_windows_postgres_event_loop()
with patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "postgres://user:pass@localhost/db"}, clear=True):
result = configure_windows_postgres_event_loop()
assert result is True
mock_set_policy.assert_called_once()
assert isinstance(asyncio.get_event_loop_policy(), MockWindowsSelectorPolicy)
@patch.object(asyncio, "WindowsSelectorEventLoopPolicy", MockWindowsSelectorPolicy, create=True)
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "postgresql://user:pass@localhost/db"}, clear=True)
@patch("asyncio.get_event_loop_policy")
@patch("asyncio.set_event_loop_policy")
def test_windows_with_selector_already_set_returns_false(self, mock_set_policy, mock_get_policy, mock_platform):
"""Test that if WindowsSelectorEventLoopPolicy is already set, returns False."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_with_selector_already_set_returns_false(self, mock_platform):
"""If selector policy is already set, function returns False and does not call set again."""
mock_platform.return_value = "Windows"
mock_policy = MagicMock(spec=MockWindowsSelectorPolicy)
mock_get_policy.return_value = mock_policy
result = configure_windows_postgres_event_loop()
with patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "postgresql://user:pass@localhost/db"}, clear=True):
asyncio.set_event_loop_policy(MockWindowsSelectorPolicy())
result = configure_windows_postgres_event_loop()
assert result is False
mock_set_policy.assert_not_called()
assert isinstance(asyncio.get_event_loop_policy(), MockWindowsSelectorPolicy)
@patch.object(asyncio, "WindowsSelectorEventLoopPolicy", MockWindowsSelectorPolicy, create=True)
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "postgresql://user:pass@localhost/db"}, clear=True)
@patch("asyncio.get_event_loop_policy")
@patch("asyncio.set_event_loop_policy")
@patch("langflow.helpers.windows_postgres_helper.logger")
def test_logging_includes_source_when_provided(self, mock_logger, mock_set_policy, mock_get_policy, mock_platform): # noqa: ARG002
"""Test that source is included in log context when provided."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_logging_includes_source_when_provided(self, mock_platform, caplog):
"""With source= provided, function succeeds and logs (no mock of logger)."""
mock_platform.return_value = "Windows"
mock_policy = MagicMock()
mock_get_policy.return_value = mock_policy
result = configure_windows_postgres_event_loop(source="test_source")
with (
patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "postgresql://user:pass@localhost/db"}, clear=True),
caplog.at_level(logging.DEBUG, logger="langflow"),
):
result = configure_windows_postgres_event_loop(source="test_source")
assert result is True
mock_logger.debug.assert_called_once_with(
"Windows PostgreSQL event loop configured",
extra={
"event_loop": "WindowsSelectorEventLoop",
"reason": "psycopg_compatibility",
"source": "test_source",
},
)
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "mysql://user:pass@localhost/db"}, clear=True)
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_with_other_database_returns_false(self, mock_platform):
"""Test Windows with non-PostgreSQL database returns False."""
"""Windows with non-PostgreSQL database returns False."""
mock_platform.return_value = "Windows"
result = configure_windows_postgres_event_loop()
with patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "mysql://user:pass@localhost/db"}, clear=True):
result = configure_windows_postgres_event_loop()
assert result is False
@patch("platform.system")
def test_docker_environment_not_affected(self, mock_platform):
"""Test that Docker environments (typically Linux) are not affected."""
mock_platform.return_value = "Linux"
os.environ[LANGFLOW_DATABASE_URL] = "postgresql://user:pass@postgres:5432/langflow"
os.environ["DOCKER_CONTAINER"] = "true"
original_policy = asyncio.get_event_loop_policy()
result = configure_windows_postgres_event_loop()
@pytest.mark.skipif(platform.system() != "Linux", reason="Docker test only meaningful on Linux")
def test_docker_environment_not_affected_no_mock(self):
"""On Linux with postgres URL, function returns False (no platform mock)."""
with patch.dict(
os.environ,
{LANGFLOW_DATABASE_URL: "postgresql://user:pass@postgres:5432/langflow", "DOCKER_CONTAINER": "true"},
clear=True,
):
original_policy = asyncio.get_event_loop_policy()
result = configure_windows_postgres_event_loop()
assert result is False
assert asyncio.get_event_loop_policy() is original_policy
@@ -178,28 +151,22 @@ class TestWindowsPostgresHelper:
],
)
@patch.object(asyncio, "WindowsSelectorEventLoopPolicy", MockWindowsSelectorPolicy, create=True)
@patch("platform.system")
@patch("asyncio.get_event_loop_policy")
@patch("asyncio.set_event_loop_policy")
def test_various_postgresql_urls_handled(self, mock_set_policy, mock_get_policy, mock_platform, db_url):
"""Test that various PostgreSQL URL formats are handled correctly."""
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_various_postgresql_urls_handled(self, mock_platform, db_url):
"""Various PostgreSQL URL formats are handled (real set_event_loop_policy)."""
mock_platform.return_value = "Windows"
mock_policy = MagicMock()
mock_get_policy.return_value = mock_policy
with patch.dict(os.environ, {LANGFLOW_DATABASE_URL: db_url}, clear=True):
result = configure_windows_postgres_event_loop()
assert result is True
mock_set_policy.assert_called_once()
assert isinstance(asyncio.get_event_loop_policy(), MockWindowsSelectorPolicy)
@patch("platform.system")
@patch.dict(os.environ, {"LANGFLOW_DATABASE_URL": "postgresql://user:pass@localhost/db"}, clear=True)
@patch("langflow.helpers.windows_postgres_helper.platform.system")
def test_windows_without_policy_class_returns_false(self, mock_platform):
"""Test that if WindowsSelectorEventLoopPolicy class is unavailable, returns False."""
"""When WindowsSelectorEventLoopPolicy is unavailable, returns False."""
mock_platform.return_value = "Windows"
with patch.object(asyncio, "WindowsSelectorEventLoopPolicy", None, create=True):
with (
patch.dict(os.environ, {LANGFLOW_DATABASE_URL: "postgresql://user:pass@localhost/db"}, clear=True),
patch.object(asyncio, "WindowsSelectorEventLoopPolicy", None, create=True),
):
result = configure_windows_postgres_event_loop()
assert result is False