189 lines
8.1 KiB
Python
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"}
|