bifroest/services/db-mcp/tests/conftest.py
2026-10-27 08:34:17 +01:00

189 lines
8.1 KiB
Python

"""Testumgebung: Dienst im selben Prozess; Test-Datenbanken mit eigenen Nutzern.
PostgreSQL: das Test-Postgres von Bifröst (Pflicht). Oracle: der Container aus docker-compose.yml
(docker compose --profile oracle up -d oracle); läuft er nicht, werden die Oracle-Tests übersprungen.
"""
import asyncio
import os
import socket
import threading
import time
import uuid
from typing import Any
import httpx2
import psycopg
import pytest
import uvicorn
from mcp import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.shared._httpx_utils import create_mcp_http_client
from dbmcp.core.config import Settings
from dbmcp.core.driver import Driver
from dbmcp.core.server import app
from dbmcp.postgres.driver import PostgresDriver
ADMIN_URL = os.environ.get("DBMCP_TEST_ADMIN_URL", "postgresql://aiw:aiw@127.0.0.1:55433/postgres")
KEY = "test-schluessel-0123456789"
PASSWORD = "lesen-nur-123"
@pytest.fixture(scope="session")
def database() -> dict[str, str]:
"""Datenbank shop mit Tabellen, eine Rolle nur mit SELECT und eine mit Schreibrechten."""
suffix = uuid.uuid4().hex[:8]
name, reader, writer = f"dbmcp_{suffix}", f"dbmcp_ro_{suffix}", f"dbmcp_rw_{suffix}"
try:
admin = psycopg.connect(ADMIN_URL, autocommit=True)
except psycopg.OperationalError as e:
pytest.exit(f"PostgreSQL nicht erreichbar ({ADMIN_URL}): {e}", returncode=2)
admin.execute(f"CREATE DATABASE {name}")
admin.execute(f"CREATE ROLE {reader} LOGIN PASSWORD '{PASSWORD}'")
admin.execute(f"CREATE ROLE {writer} LOGIN PASSWORD '{PASSWORD}'")
with psycopg.connect(ADMIN_URL.rsplit("/", 1)[0] + f"/{name}", autocommit=True) as db:
db.execute("""
CREATE TABLE kunden (id int PRIMARY KEY, name text NOT NULL, seit date, umsatz numeric(10,2));
COMMENT ON TABLE kunden IS 'Alle Kunden';
COMMENT ON COLUMN kunden.umsatz IS 'Umsatz in Euro';
INSERT INTO kunden SELECT i, 'Kunde ' || i, date '2024-01-01' + i, i * 10.5 FROM generate_series(1, 1200) i;
CREATE SCHEMA vertrieb;
CREATE VIEW vertrieb.top AS SELECT * FROM kunden WHERE umsatz > 10000;
ANALYZE kunden;
""")
db.execute(f"GRANT USAGE ON SCHEMA public, vertrieb TO {reader}, {writer}")
db.execute(f"GRANT SELECT ON ALL TABLES IN SCHEMA public, vertrieb TO {reader}, {writer}")
db.execute(f"GRANT INSERT, UPDATE, DELETE ON kunden TO {writer}")
yield {"database": name, "reader": reader, "writer": writer}
admin.execute(f"DROP DATABASE {name} WITH (FORCE)")
admin.execute(f"DROP ROLE {reader}")
admin.execute(f"DROP ROLE {writer}")
admin.close()
@pytest.fixture
def settings() -> Settings:
return Settings(api_keys={KEY: "bifroest"}, allow_local=True, max_rows=100, query_timeout=2)
class Service:
"""DB-Dienst über HTTP im selben Prozess."""
def __init__(self, settings: Settings, driver: Driver | None = None):
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
self.port = s.getsockname()[1]
driver = driver or PostgresDriver(settings)
self.server = uvicorn.Server(uvicorn.Config(app(driver, settings), host="127.0.0.1",
port=self.port, log_level="warning", lifespan="on"))
self.thread = threading.Thread(target=self.server.run, daemon=True)
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.port}"
def __enter__(self) -> "Service":
self.thread.start()
for _ in range(100):
if self.server.started:
return self
time.sleep(0.05)
raise RuntimeError("Dienst startet nicht")
def __exit__(self, *exc) -> None:
self.server.should_exit = True
self.thread.join(5)
def call(self, tool: str, arguments: dict[str, Any] | None = None, headers: dict[str, str] | None = None,
key: str | None = KEY) -> Any:
async def main() -> Any:
all_headers = {**({"Authorization": f"Bearer {key}"} if key else {}), **(headers or {})}
http = create_mcp_http_client(headers=all_headers, timeout=httpx2.Timeout(10))
async with Client(streamable_http_client(f"{self.url}/mcp", http_client=http), cache=None) as client:
if tool == "__list__":
return await client.list_tools()
return await client.call_tool(tool, arguments or {})
return asyncio.run(main())
@pytest.fixture
def service(settings):
with Service(settings) as running:
yield running
@pytest.fixture
def reader(database) -> dict[str, str]:
return {"X-DB-Host": "127.0.0.1", "X-DB-Port": "55433", "X-DB-Database": database["database"],
"X-DB-User": database["reader"], "X-DB-Password": PASSWORD, "X-DB-SSLMode": "disable"}
ORACLE = os.environ.get("DBMCP_TEST_ORACLE", "system/aiw@127.0.0.1:55434/FREEPDB1")
@pytest.fixture(scope="session")
def oracle_database() -> dict[str, str]:
"""Schema mit Tabelle, View und Statistik, ein Nutzer nur mit SELECT und einer mit Schreibrechten.
Nur SQL, das es in Oracle 19c schon gibt."""
import oracledb
credentials, _, address = ORACLE.rpartition("@")
user, _, password = credentials.partition("/")
host_port, _, service = address.partition("/")
host, _, port = host_port.partition(":")
try:
admin = oracledb.connect(user=user, password=password, host=host, port=int(port), service_name=service,
tcp_connect_timeout=3, retry_count=0)
except oracledb.Error as e:
pytest.skip(f"Oracle nicht erreichbar ({address}): {str(e).splitlines()[0]}")
suffix = uuid.uuid4().hex[:6].upper()
owner, reader, writer = f"DBMCP_OWN_{suffix}", f"DBMCP_RO_{suffix}", f"DBMCP_RW_{suffix}"
cursor = admin.cursor()
for name in (owner, reader, writer):
cursor.execute(f'CREATE USER {name} IDENTIFIED BY "{PASSWORD}"')
cursor.execute(f"GRANT CREATE SESSION TO {name}")
cursor.execute(f"ALTER USER {owner} QUOTA UNLIMITED ON USERS")
for statement in [
f"CREATE TABLE {owner}.kunden (id NUMBER(10) PRIMARY KEY, name VARCHAR2(100) NOT NULL, seit DATE, "
"umsatz NUMBER(10,2), notiz CLOB) TABLESPACE USERS",
f"COMMENT ON TABLE {owner}.kunden IS 'Alle Kunden'",
f"COMMENT ON COLUMN {owner}.kunden.umsatz IS 'Umsatz in Euro'",
f"INSERT INTO {owner}.kunden SELECT level, 'Kunde ' || level, DATE '2024-01-01' + level, level * 10.5, "
"CASE WHEN level = 1 THEN 'langer Text' END FROM dual CONNECT BY level <= 1200",
f"CREATE VIEW {owner}.top AS SELECT * FROM {owner}.kunden WHERE umsatz > 10000",
f"GRANT SELECT ON {owner}.kunden TO {reader}, {writer}",
f"GRANT SELECT ON {owner}.top TO {reader}, {writer}",
f"GRANT INSERT, UPDATE, DELETE ON {owner}.kunden TO {writer}",
]:
cursor.execute(statement)
admin.commit()
cursor.callproc("DBMS_STATS.GATHER_TABLE_STATS", [owner, "KUNDEN"])
# Schreibgeschützte Transaktionen lesen frisch angelegte Tabellen erst nach ein paar Sekunden (ORA-01466)
for _ in range(50):
try:
cursor.execute("SET TRANSACTION READ ONLY")
cursor.execute(f"SELECT count(*) FROM {owner}.kunden")
admin.rollback()
break
except oracledb.DatabaseError:
admin.rollback()
time.sleep(0.2)
yield {"host": host, "port": port, "service": service, "owner": owner, "reader": reader, "writer": writer,
"admin": user, "admin_password": password}
for name in (reader, writer, owner):
cursor.execute(f"DROP USER {name} CASCADE")
admin.close()
@pytest.fixture
def oracle_service(settings):
from dbmcp.oracle.driver import OracleDriver
with Service(settings, OracleDriver(settings)) as running:
yield running
@pytest.fixture
def oracle_reader(oracle_database) -> dict[str, str]:
return {"X-DB-Host": oracle_database["host"], "X-DB-Port": oracle_database["port"],
"X-DB-Database": oracle_database["service"], "X-DB-User": oracle_database["reader"],
"X-DB-Password": PASSWORD, "X-DB-SSLMode": "disable"}