"""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"}