blob: fbf1a5a220dcb19f36b80955f41ddf7a6e37667c [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from doris_mcp_server.utils.db import (
DorisConnection,
DorisSessionCache,
get_first_sql_keyword,
)
@pytest.fixture
def session_cache():
"""Provides a DorisSessionCache instance with a mock connection manager."""
connection_manager = MagicMock()
cache = DorisSessionCache(connection_manager=connection_manager)
yield cache, connection_manager
class TestDorisSessionCache:
def test_initialization(self, session_cache):
cache, _ = session_cache
assert cache.cache_system_session is True
assert cache.cache_user_session is False
assert not cache.cached
def test_should_cache(self, session_cache):
cache, _ = session_cache
assert cache._should_cache("query") is True
assert cache._should_cache("system") is True
assert cache._should_cache("user-test-session-id") is False
cache.cache_user_session = True
assert cache._should_cache("user-test-session-id") is True
def test_save_and_get_session(self, session_cache):
cache, _ = session_cache
mock_connection = MagicMock(spec=DorisConnection)
mock_connection.session_id = "query"
cache.save(mock_connection)
retrieved_conn = cache.get("query")
assert retrieved_conn is mock_connection
mock_user_connection = MagicMock(spec=DorisConnection)
mock_user_connection.session_id = "user-test-session-id"
cache.save(mock_user_connection)
assert cache.get("user-test-session-id") is None
cache.cache_user_session = True
cache.save(mock_user_connection)
retrieved_user_conn = cache.get("user-test-session-id")
assert retrieved_user_conn is mock_user_connection
def test_remove_session(self, session_cache):
cache, _ = session_cache
mock_connection = MagicMock(spec=DorisConnection)
mock_connection.session_id = "system"
cache.save(mock_connection)
assert cache.get("system") is not None
cache.remove("system")
assert cache.get("system") is None
def test_clear_cache(self, session_cache):
cache, connection_manager = session_cache
mock_conn1 = MagicMock(spec=DorisConnection)
mock_conn1.session_id = "query"
mock_conn2 = MagicMock(spec=DorisConnection)
mock_conn2.session_id = "system"
cache.save(mock_conn1)
cache.save(mock_conn2)
assert len(cache.cached) == 2
cache.clear()
assert not cache.cached
connection_manager.release_connection.assert_any_call("query", mock_conn1)
connection_manager.release_connection.assert_any_call("system", mock_conn2)
assert connection_manager.release_connection.call_count == 2
class TestGetFirstSqlKeyword:
"""Unit tests for get_first_sql_keyword.
Used by query_executor.py:689 to detect SELECT before cursor.execute
(where cursor.description is not yet available), so the auto-injected
LIMIT {max_rows} cap also works when the SQL is comment-prefixed.
"""
def test_plain_select(self):
assert get_first_sql_keyword("SELECT 1") == "SELECT"
def test_leading_whitespace(self):
assert get_first_sql_keyword(" \n\t SELECT 1") == "SELECT"
def test_lowercase(self):
assert get_first_sql_keyword("select 1") == "SELECT"
def test_line_comment_then_select(self):
sql = "-- a leading note\nSELECT 1"
assert get_first_sql_keyword(sql) == "SELECT"
def test_block_comment_then_select(self):
sql = "/* note */ SELECT 1"
assert get_first_sql_keyword(sql) == "SELECT"
def test_multiline_block_comment_then_select(self):
sql = "/*\n multi\n line\n*/\nSELECT 1"
assert get_first_sql_keyword(sql) == "SELECT"
def test_mixed_whitespace_and_comments(self):
sql = " -- one\n /* two */ \n SELECT 1"
assert get_first_sql_keyword(sql) == "SELECT"
def test_comment_then_with_cte(self):
sql = "-- note\nWITH x AS (SELECT 1) SELECT * FROM x"
assert get_first_sql_keyword(sql) == "WITH"
def test_non_select_unaffected(self):
assert get_first_sql_keyword("INSERT INTO t VALUES (1)") == "INSERT"
assert get_first_sql_keyword("-- c\nINSERT INTO t VALUES (1)") == "INSERT"
def test_empty_and_only_comments(self):
assert get_first_sql_keyword("") == ""
assert get_first_sql_keyword(" ") == ""
assert get_first_sql_keyword("-- only a comment") == ""
assert get_first_sql_keyword("/* only */") == ""
def _make_doris_connection(cursor_description, fetchall_rows, rowcount=0):
"""Build a DorisConnection whose underlying cursor returns the given values.
The driver-level cursor is fully mocked: only `description`, `fetchall()`
and `rowcount` matter for the result-set-detection branch we want to test.
"""
cursor = MagicMock()
cursor.execute = AsyncMock(return_value=None)
cursor.fetchall = AsyncMock(return_value=fetchall_rows)
cursor.close = AsyncMock(return_value=None)
cursor.description = cursor_description
cursor.rowcount = rowcount
raw_connection = MagicMock()
raw_connection.cursor = AsyncMock(return_value=cursor)
raw_connection.ensure_closed = AsyncMock()
return DorisConnection(connection=raw_connection, session_id="test")
def _make_bounded_doris_connection(cursor_description, fetchmany_rows):
cursor = MagicMock()
cursor.execute = AsyncMock(return_value=None)
cursor.fetchmany = AsyncMock(side_effect=fetchmany_rows)
cursor.close = AsyncMock(return_value=None)
cursor.description = cursor_description
cursor.rowcount = 0
raw_connection = MagicMock()
raw_connection.cursor = AsyncMock(return_value=cursor)
raw_connection.ensure_closed = AsyncMock()
return (
DorisConnection(connection=raw_connection, session_id="bounded"),
cursor,
raw_connection,
)
class TestExecuteResultSetDetection:
"""Behavior contract for DorisConnection.execute().
These tests pin the user-facing contract: any statement the driver
reports as producing a result set must have its rows returned, and any
statement that does not produce a result set must report rowcount.
Guards against regression of:
- Issue #62 Bug 5 (CTE / WITH returning empty data)
- The leading-comment bug (SELECT prefixed by `--` or `/* */` returning
empty data while row_count was non-zero)
- Future "missing keyword in the whitelist" bugs of the same class
The tests deliberately do not assert anything about how the SQL text is
parsed — they only assert that when `cursor.description` is populated,
rows are fetched, regardless of the SQL phrasing.
"""
@pytest.mark.parametrize(
"sql",
[
"SELECT 1",
" SELECT 1",
"-- leading line comment\nSELECT 1",
"/* leading block comment */ SELECT 1",
"/*\n multi\n line\n*/\nSELECT 1",
" -- one\n /* two */ \n SELECT 1",
"(SELECT 1)",
"WITH t AS (SELECT 1) SELECT * FROM t",
"-- comment\nWITH t AS (SELECT 1) SELECT * FROM t",
"SHOW TABLES",
"DESC some_table",
"EXPLAIN SELECT 1",
],
ids=[
"plain_select",
"leading_whitespace",
"line_comment_then_select",
"block_comment_then_select",
"multiline_block_comment",
"mixed_whitespace_and_comments",
"parenthesized_select",
"with_cte",
"comment_then_with_cte",
"show",
"desc",
"explain",
],
)
async def test_returns_rows_when_driver_reports_result_set(self, sql):
rows = [{"col": 1}]
conn = _make_doris_connection(
cursor_description=[("col", None, None, None, None, None, None)],
fetchall_rows=rows,
)
result = await conn.execute(sql)
assert result.data == rows
assert result.row_count == len(rows)
async def test_can_skip_masking_without_skipping_security_validation(self):
rows = [{"UserIdentity": "'root'@'%'", "Roles": "operator"}]
conn = _make_doris_connection(
cursor_description=[
("UserIdentity", None, None, None, None, None, None),
("Roles", None, None, None, None, None, None),
],
fetchall_rows=rows,
)
security_manager = MagicMock()
security_manager.validate_sql_security = AsyncMock(
return_value=MagicMock(
is_valid=True,
risk_level="low",
blocked_operations=[],
)
)
security_manager.apply_data_masking = AsyncMock(
return_value=[{"UserIdentity": "masked", "Roles": "operator"}]
)
conn.security_manager = security_manager
auth_context = object()
result = await conn.execute(
"SHOW ALL GRANTS",
auth_context=auth_context,
mask_result=False,
)
assert result.data == rows
security_manager.validate_sql_security.assert_awaited_once_with(
"SHOW ALL GRANTS",
auth_context,
)
security_manager.apply_data_masking.assert_not_awaited()
@pytest.mark.parametrize(
"sql, affected",
[
("INSERT INTO t VALUES (1)", 1),
("UPDATE t SET x = 1", 5),
("DELETE FROM t WHERE x = 1", 3),
("CREATE TABLE t (x INT)", 0),
],
)
async def test_no_fetch_when_driver_reports_no_result_set(self, sql, affected):
conn = _make_doris_connection(
cursor_description=None,
fetchall_rows=[],
rowcount=affected,
)
result = await conn.execute(sql)
assert result.data == []
assert result.row_count == affected
class TestBoundedResultFetch:
async def test_row_limit_stops_streaming_and_closes_physical_connection(self):
conn, cursor, raw_connection = _make_bounded_doris_connection(
[("id", None, None, None, None, None, None)],
[[{"id": 1}, {"id": 2}, {"id": 3}]],
)
result = await conn.execute(
"WITH data AS (...) SELECT * FROM data",
max_rows=2,
max_bytes=4096,
)
assert result.data == [{"id": 1}, {"id": 2}]
assert result.row_count == 2
assert result.metadata["truncated"] is True
assert result.metadata["truncation_reason"] == "row_limit"
assert result.metadata["result_bytes"] <= 4096
cursor.fetchall.assert_not_called()
cursor.close.assert_not_awaited()
raw_connection.ensure_closed.assert_awaited_once()
assert conn.is_healthy is False
async def test_byte_limit_is_measured_after_masking(self):
conn, cursor, raw_connection = _make_bounded_doris_connection(
[("secret", None, None, None, None, None, None)],
[[{"secret": "x"}], []],
)
security_manager = MagicMock()
security_manager.validate_sql_security = AsyncMock(
return_value=MagicMock(
is_valid=True,
risk_level="low",
blocked_operations=[],
)
)
security_manager.apply_data_masking = AsyncMock(
return_value=[{"secret": "masked-value-that-is-too-large"}]
)
conn.security_manager = security_manager
result = await conn.execute(
"SELECT secret FROM t",
auth_context=object(),
max_rows=10,
max_bytes=24,
)
assert result.data == []
assert result.metadata["truncated"] is True
assert result.metadata["truncation_reason"] == "byte_limit"
assert result.metadata["result_bytes"] == 2
security_manager.apply_data_masking.assert_awaited_once()
cursor.close.assert_not_awaited()
raw_connection.ensure_closed.assert_awaited_once()
async def test_task_cancellation_closes_connection_and_propagates(self):
conn, cursor, raw_connection = _make_bounded_doris_connection(
[("id", None, None, None, None, None, None)],
[],
)
async def never_returns(*args, **kwargs):
del args, kwargs
await asyncio.Event().wait()
cursor.execute.side_effect = never_returns
task = asyncio.create_task(
conn.execute(
"SELECT SLEEP(30)",
max_rows=1,
max_bytes=4096,
)
)
await asyncio.sleep(0)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
raw_connection.ensure_closed.assert_awaited_once()
cursor.close.assert_not_awaited()
assert conn.is_healthy is False
async def test_bounded_fetch_failure_closes_physical_connection(self):
conn, cursor, raw_connection = _make_bounded_doris_connection(
[("id", None, None, None, None, None, None)],
[],
)
cursor.fetchmany.side_effect = RuntimeError("stream failed")
with pytest.raises(RuntimeError, match="stream failed"):
await conn.execute(
"SELECT id FROM data",
max_rows=10,
max_bytes=4096,
)
cursor.close.assert_not_awaited()
raw_connection.ensure_closed.assert_awaited_once()
assert conn.is_healthy is False