blob: 801dde26cccb38bf27db90ccd192eb527ff2d8a2 [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.
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from doris_mcp_server.utils import db as db_module
from doris_mcp_server.utils.db import (
DorisConnection,
DorisConnectionManager,
DorisUserAuthenticationError,
DorisUserPoolMissingError,
QueryResult,
)
from doris_mcp_server.utils.security import (
AuthContext,
reset_auth_context,
set_current_auth_context,
)
class FakeAuthConnection:
def __init__(self):
self.closed = False
self.close_calls = 0
def close(self):
self.close_calls += 1
self.closed = True
class FakeRawConnection:
def __init__(self, label):
self.label = label
self.closed = False
self.ensure_closed_calls = 0
async def ensure_closed(self):
self.ensure_closed_calls += 1
self.closed = True
class FakePool:
def __init__(self, name):
self.name = name
self.closed = False
self.size = 0
self.freesize = 0
self.minsize = 0
self.maxsize = 5
self.acquire_calls = 0
self.release_calls = []
self.close_calls = 0
self.wait_closed_calls = 0
async def acquire(self):
self.acquire_calls += 1
return FakeRawConnection(f"{self.name}:{self.acquire_calls}")
def release(self, connection):
self.release_calls.append(connection)
def close(self):
self.close_calls += 1
self.closed = True
async def wait_closed(self):
self.wait_closed_calls += 1
def manager_config():
return SimpleNamespace(
database=SimpleNamespace(
host="127.0.0.1",
port=9030,
user="root",
password="root_pw",
database="default_cluster:test_db",
charset="utf8",
min_connections=0,
max_connections=20,
max_connection_age=3600,
connection_timeout=1,
),
security=SimpleNamespace(enable_token_auth=False),
)
@pytest.fixture
def manager():
return DorisConnectionManager(manager_config())
@pytest.mark.asyncio
async def test_pool_warmup_does_not_exceed_configured_capacity(manager):
pool = FakePool("global")
manager.pool = pool
manager.maxsize = 1
await manager._warmup_pool()
assert pool.acquire_calls == 1
assert len(pool.release_calls) == 1
def doris_context(user="alice", token=""):
return AuthContext(
user_id=user,
auth_method="doris_oauth",
doris_user=user,
pool_key=f"doris_user:{user}",
token=token,
)
@pytest.mark.asyncio
async def test_authenticate_doris_user_success_does_not_create_pool(
manager, monkeypatch
):
connect = AsyncMock(return_value=FakeAuthConnection())
create_pool = AsyncMock()
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager.authenticate_doris_user("alice", "correct")
assert connect.await_count == 1
assert connect.await_args.kwargs["user"] == "alice"
assert connect.await_args.kwargs["db"] == "information_schema"
assert create_pool.await_count == 0
assert manager.has_doris_user_pool("alice") is False
@pytest.mark.asyncio
async def test_wrong_password_first_login_fails_without_pool(manager, monkeypatch):
connect = AsyncMock(side_effect=RuntimeError("access denied"))
create_pool = AsyncMock()
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
with pytest.raises(DorisUserAuthenticationError) as exc:
await manager.create_or_replace_doris_user_pool("alice", "wrong")
assert exc.value.error_code == "DORIS_AUTHENTICATION_FAILED"
assert create_pool.await_count == 0
assert manager.has_doris_user_pool("alice") is False
@pytest.mark.asyncio
async def test_repeat_login_reverifies_and_reuses_existing_pool(manager, monkeypatch):
pool = FakePool("alice-v1")
connect = AsyncMock(side_effect=[FakeAuthConnection(), FakeAuthConnection()])
create_pool = AsyncMock(return_value=pool)
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager.create_or_replace_doris_user_pool("alice", "pw1")
first_meta = manager.doris_user_pool_meta["alice"]
await manager.create_or_replace_doris_user_pool("alice", "pw1")
assert connect.await_count == 2
assert create_pool.await_count == 1
assert create_pool.await_args.kwargs["db"] == "information_schema"
assert manager.doris_user_pools["alice"] is pool
assert (
manager.doris_user_pool_meta["alice"].credential_fingerprint
== first_meta.credential_fingerprint
)
@pytest.mark.asyncio
async def test_wrong_password_after_existing_pool_preserves_old_pool(
manager, monkeypatch
):
old_pool = FakePool("alice-v1")
connect = AsyncMock(
side_effect=[FakeAuthConnection(), RuntimeError("access denied")]
)
create_pool = AsyncMock(return_value=old_pool)
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager.create_or_replace_doris_user_pool("alice", "pw1")
old_meta = manager.doris_user_pool_meta["alice"]
with pytest.raises(DorisUserAuthenticationError):
await manager.create_or_replace_doris_user_pool("alice", "wrong")
assert manager.doris_user_pools["alice"] is old_pool
assert manager.doris_user_pool_meta["alice"].owner_id == old_meta.owner_id
assert (
manager.doris_user_pool_meta["alice"].credential_fingerprint
== old_meta.credential_fingerprint
)
assert create_pool.await_count == 1
@pytest.mark.asyncio
async def test_soft_replace_releases_old_checked_out_connection_to_old_owner(
manager, monkeypatch
):
old_pool = FakePool("alice-v1")
new_pool = FakePool("alice-v2")
connect = AsyncMock(side_effect=[FakeAuthConnection(), FakeAuthConnection()])
create_pool = AsyncMock(side_effect=[old_pool, new_pool])
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager.create_or_replace_doris_user_pool("alice", "pw1")
checked_out = await manager.get_connection_for_doris_user("alice", "s1")
await manager.create_or_replace_doris_user_pool("alice", "pw2")
assert manager.doris_user_pools["alice"] is new_pool
assert old_pool.close_calls == 1
await manager.release_connection_for_doris_user("alice", checked_out)
assert old_pool.release_calls == [checked_out.connection]
assert new_pool.release_calls == []
@pytest.mark.asyncio
async def test_new_pool_create_failure_preserves_existing_pool(manager, monkeypatch):
old_pool = FakePool("alice-v1")
connect = AsyncMock(side_effect=[FakeAuthConnection(), FakeAuthConnection()])
create_pool = AsyncMock(side_effect=[old_pool, RuntimeError("create failed")])
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager.create_or_replace_doris_user_pool("alice", "pw1")
old_meta = manager.doris_user_pool_meta["alice"]
with pytest.raises(RuntimeError):
await manager.create_or_replace_doris_user_pool("alice", "pw2")
assert manager.doris_user_pools["alice"] is old_pool
assert manager.doris_user_pool_meta["alice"].owner_id == old_meta.owner_id
assert (
manager.doris_user_pool_meta["alice"].credential_fingerprint
== old_meta.credential_fingerprint
)
@pytest.mark.asyncio
async def test_doris_oauth_pool_missing_does_not_fallback_to_token_or_global(manager):
manager.pool = FakePool("global")
manager.get_connection_for_token = AsyncMock()
manager._recover_pool_with_lock = AsyncMock()
token = set_current_auth_context(doris_context(token="static-token"))
try:
with pytest.raises(DorisUserPoolMissingError) as exc:
await manager.get_connection("s1")
finally:
reset_auth_context(token)
assert exc.value.error_code == "DORIS_OAUTH_POOL_MISSING"
assert manager.pool.acquire_calls == 0
assert manager.get_connection_for_token.await_count == 0
assert manager._recover_pool_with_lock.await_count == 0
@pytest.mark.asyncio
async def test_execute_query_pool_missing_does_not_fallback_to_global_or_token(manager):
manager.pool = FakePool("global")
manager.get_connection_for_token = AsyncMock()
manager._recover_pool_with_lock = AsyncMock()
with pytest.raises(DorisUserPoolMissingError):
await manager.execute_query(
"s1",
"SELECT 1",
auth_context=doris_context(token="static-token"),
)
assert manager.pool.acquire_calls == 0
assert manager.get_connection_for_token.await_count == 0
assert manager._recover_pool_with_lock.await_count == 0
@pytest.mark.asyncio
async def test_execute_query_releases_to_captured_doris_user_owner(
manager, monkeypatch
):
pool = FakePool("alice-v1")
connect = AsyncMock(return_value=FakeAuthConnection())
create_pool = AsyncMock(return_value=pool)
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager.create_or_replace_doris_user_pool("alice", "pw1")
async def fake_execute(
self,
sql,
params=None,
auth_context=None,
*,
max_rows=None,
max_bytes=None,
):
del max_rows, max_bytes
return QueryResult(
data=[{"ok": 1}], metadata={}, execution_time=0.0, row_count=1, sql=sql
)
monkeypatch.setattr(DorisConnection, "execute", fake_execute)
result = await manager.execute_query("s1", "SELECT 1", auth_context=doris_context())
assert result.row_count == 1
assert len(pool.release_calls) == 1
@pytest.mark.asyncio
async def test_get_connection_context_uses_owner_based_release(manager, monkeypatch):
pool = FakePool("alice-v1")
connect = AsyncMock(return_value=FakeAuthConnection())
create_pool = AsyncMock(return_value=pool)
monkeypatch.setattr(db_module.aiomysql, "connect", connect)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
await manager.create_or_replace_doris_user_pool("alice", "pw1")
token = set_current_auth_context(doris_context())
try:
async with manager.get_connection_context("s1") as connection:
assert connection.owner_pool is pool
finally:
reset_auth_context(token)
assert len(pool.release_calls) == 1
@pytest.mark.asyncio
async def test_token_database_config_takes_priority_over_valid_global_config(
manager,
monkeypatch,
):
token = "tenant-token"
token_db_config = SimpleNamespace(
host="tenant-fe",
port=19030,
user="tenant_reader",
password="tenant_pw",
database="tenant_db",
charset="utf8mb4",
)
manager.token_manager = SimpleNamespace(
get_database_config_by_token=lambda raw: (
token_db_config if raw == token else None
)
)
token_pool = FakePool("tenant")
create_pool = AsyncMock(return_value=token_pool)
monkeypatch.setattr(manager, "_create_pool_with_config", create_pool)
pool, selected_config = await manager.get_pool_for_token(token)
assert pool is token_pool
assert selected_config == {
"host": "tenant-fe",
"port": 19030,
"user": "tenant_reader",
"password": "tenant_pw",
"database": "tenant_db",
"charset": "utf8mb4",
}
create_pool.assert_awaited_once_with(selected_config)
assert manager.original_db_config["user"] == "root"
assert manager.active_db_config["user"] == "root"
@pytest.mark.asyncio
async def test_static_token_release_uses_captured_owner_pool(manager, monkeypatch):
token = "static-token"
old_pool = FakePool("token-v1")
new_pool = FakePool("token-v2")
token_db_config = SimpleNamespace(
host="token-host",
port=9030,
user="token_user",
password="token_pw",
database="token_db",
charset="utf8",
)
manager.token_manager = SimpleNamespace(
get_database_config_by_token=lambda raw: token_db_config
)
create_pool = AsyncMock(return_value=old_pool)
monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
connection = await manager.get_connection_for_token(token, "s1")
token_hash = manager._get_token_hash(token)
manager.token_pools[token_hash] = new_pool
await manager.release_connection_for_token(token, connection)
assert old_pool.release_calls == [connection.connection]
assert new_pool.release_calls == []
@pytest.mark.parametrize(
("pool_kind", "route_key"),
[
("doris_user", "doris_user:alice"),
("static_token", "static_token:tokenhash"),
],
)
@pytest.mark.asyncio
async def test_release_routed_connection_releases_closed_raw_to_captured_owner(
manager,
pool_kind,
route_key,
):
old_owner = FakePool(f"{pool_kind}-old")
current_pool = FakePool(f"{pool_kind}-current")
raw_connection = FakeRawConnection("closed-raw")
raw_connection.closed = True
connection = DorisConnection(
raw_connection,
"s1",
pool_kind=pool_kind,
route_key=route_key,
owner_id=f"{route_key}:gen:1",
generation=1,
owner_pool=old_owner,
)
if pool_kind == "doris_user":
manager.doris_user_pools["alice"] = current_pool
else:
manager.token_pools["tokenhash"] = current_pool
await manager.release_routed_connection(connection)
assert old_owner.release_calls == [raw_connection]
assert current_pool.release_calls == []
assert raw_connection.ensure_closed_calls == 0
@pytest.mark.asyncio
async def test_release_routed_connection_force_closes_unhealthy_raw(manager):
owner = FakePool("owner")
raw_connection = FakeRawConnection("unhealthy-raw")
connection = DorisConnection(
raw_connection,
"s1",
pool_kind="static_token",
route_key="static_token:tokenhash",
owner_id="static_token:tokenhash:gen:1",
generation=1,
owner_pool=owner,
)
connection.is_healthy = False
await manager.release_routed_connection(connection)
assert owner.release_calls == [raw_connection]
assert raw_connection.ensure_closed_calls == 1
@pytest.mark.asyncio
async def test_configure_for_token_validates_dedicated_route_without_global_mutation(
manager,
monkeypatch,
):
token = "tenant-token"
manager.token_manager = SimpleNamespace(
get_database_config_by_token=lambda raw: (
SimpleNamespace(
host="tenant-fe",
port=19030,
user="tenant_reader",
password="tenant_pw",
database="tenant_db",
charset="utf8",
)
if raw == token
else None
)
)
connection = SimpleNamespace(
execute=AsyncMock(
return_value=QueryResult(
data=[{"connection_check": 1}],
metadata={},
execution_time=0.0,
row_count=1,
sql="SELECT 1 AS connection_check",
)
)
)
get_connection = AsyncMock(return_value=connection)
release_connection = AsyncMock()
monkeypatch.setattr(manager, "get_connection_for_token", get_connection)
monkeypatch.setattr(
manager,
"release_connection_for_token",
release_connection,
)
original_config = manager.original_db_config.copy()
active_config = manager.active_db_config.copy()
success, source = await manager.configure_for_token(token)
assert success is True
assert source == "token-bound"
get_connection.assert_awaited_once()
connection.execute.assert_awaited_once_with(
"SELECT 1 AS connection_check",
mask_result=False,
max_rows=1,
max_bytes=256,
)
release_connection.assert_awaited_once_with(token, connection)
assert manager.original_db_config == original_config
assert manager.active_db_config == active_config