- move the relational data center to MySQL and a standalone workbench\n- add Interface Center API credentials, policies, logs, and DBeaver SSH guidance\n- harden authentication and deployment while retiring unused management surfaces
236 lines
9.1 KiB
Python
236 lines
9.1 KiB
Python
import unittest
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
from app.data_platform import interface_service, mysql_service
|
|
from app.data_platform.mysql_service import (
|
|
_managed_index_name,
|
|
_mysql_default,
|
|
_mysql_type,
|
|
project_database_name,
|
|
validate_console_sql,
|
|
)
|
|
from app.main import app
|
|
|
|
|
|
class _FakeCursor:
|
|
def __init__(self) -> None:
|
|
self.executed: list[tuple[str, tuple[object, ...]]] = []
|
|
self.rowcount = 1
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, _exc_type, _exc, _traceback) -> None:
|
|
return None
|
|
|
|
async def execute(self, statement: str, params: tuple[object, ...] = ()) -> None:
|
|
self.executed.append((statement, params))
|
|
|
|
async def fetchone(self):
|
|
return None
|
|
|
|
|
|
class _FakeConnection:
|
|
def __init__(self) -> None:
|
|
self.cursor_instance = _FakeCursor()
|
|
self.commit_count = 0
|
|
|
|
def cursor(self) -> _FakeCursor:
|
|
return self.cursor_instance
|
|
|
|
async def commit(self) -> None:
|
|
self.commit_count += 1
|
|
|
|
|
|
class _FakeConnectionContext:
|
|
def __init__(self, connection: _FakeConnection) -> None:
|
|
self.connection = connection
|
|
|
|
async def __aenter__(self) -> _FakeConnection:
|
|
return self.connection
|
|
|
|
async def __aexit__(self, _exc_type, _exc, _traceback) -> None:
|
|
return None
|
|
|
|
|
|
class MySQLDataCenterContractTests(unittest.TestCase):
|
|
def test_physical_database_name_is_stable_and_mysql_safe(self) -> None:
|
|
self.assertEqual(project_database_name("yunyou_libo"), "yunyou_libo")
|
|
long_name = "a" * 63
|
|
physical = project_database_name(long_name)
|
|
self.assertEqual(physical, long_name)
|
|
self.assertLessEqual(len(_managed_index_name("uq", long_name)), 64)
|
|
|
|
def test_postgresql_registry_types_translate_to_mysql(self) -> None:
|
|
self.assertEqual(_mysql_type("UUID"), "CHAR(36)")
|
|
self.assertEqual(_mysql_type("JSONB"), "JSON")
|
|
self.assertEqual(_mysql_type("TIMESTAMPTZ"), "DATETIME(6)")
|
|
self.assertEqual(_mysql_type("DOUBLE PRECISION"), "DOUBLE")
|
|
self.assertEqual(_mysql_type("NUMERIC(12,2)"), "DECIMAL(12,2)")
|
|
self.assertEqual(_mysql_default("'{}'::jsonb", "JSON"), "(JSON_OBJECT())")
|
|
|
|
def test_console_accepts_mysql_discovery_queries_and_frontend_sql(self) -> None:
|
|
statement, keyword = validate_console_sql("SHOW TABLES;", "demo")
|
|
self.assertEqual((statement, keyword), ("SHOW TABLES", "show"))
|
|
statement, keyword = validate_console_sql(
|
|
'SELECT * FROM "hotel_profiles" WHERE deleted_at IS NULL LIMIT 100;',
|
|
"demo",
|
|
)
|
|
self.assertEqual(keyword, "select")
|
|
self.assertIn('"hotel_profiles"', statement)
|
|
|
|
def test_console_rejects_cross_database_or_privileged_sql(self) -> None:
|
|
for sql in (
|
|
"SHOW DATABASES",
|
|
"SELECT * FROM platform_control.api_clients",
|
|
"SELECT * FROM other_database.hotel_profiles",
|
|
"SELECT * FROM unrelated_database.hotel_profiles",
|
|
"SHOW TABLES FROM unrelated_database",
|
|
"DROP TABLE hotel_profiles",
|
|
):
|
|
with self.subTest(sql=sql), self.assertRaises(ValueError):
|
|
validate_console_sql(sql, "demo")
|
|
|
|
def test_console_is_read_only_and_blocks_high_risk_queries(self) -> None:
|
|
for sql in (
|
|
"DELETE FROM hotel_profiles WHERE id='1'",
|
|
"UPDATE hotel_profiles SET name='x' WHERE id='1'",
|
|
"SELECT SLEEP(10)",
|
|
"SELECT * FROM hotel_profiles FOR UPDATE",
|
|
"SELECT id INTO @captured FROM hotel_profiles LIMIT 1",
|
|
"WITH changed AS (SELECT 1) DELETE FROM hotel_profiles WHERE id='1'",
|
|
):
|
|
with self.subTest(sql=sql), self.assertRaises(ValueError):
|
|
validate_console_sql(sql, "demo")
|
|
|
|
def test_enabled_console_writes_still_require_scoped_update(self) -> None:
|
|
with patch.object(mysql_service.settings, "data_sql_console_write_enabled", True):
|
|
with self.assertRaisesRegex(ValueError, "WHERE"):
|
|
validate_console_sql("UPDATE hotel_profiles SET name='x'", "demo")
|
|
statement, keyword = validate_console_sql(
|
|
"UPDATE hotel_profiles SET name='x' WHERE id='1'",
|
|
"demo",
|
|
)
|
|
self.assertEqual(keyword, "update")
|
|
self.assertIn("WHERE id='1'", statement)
|
|
with self.assertRaisesRegex(ValueError, "物理删除"):
|
|
validate_console_sql(
|
|
"DELETE FROM hotel_profiles WHERE id='1'",
|
|
"demo",
|
|
)
|
|
|
|
def test_interface_center_routes_are_registered(self) -> None:
|
|
paths = {route.path for route in app.routes}
|
|
for path in (
|
|
"/v1/admin/interface-center/summary",
|
|
"/v1/admin/interface-center/clients",
|
|
"/v1/admin/interface-center/policies",
|
|
"/v1/admin/interface-center/logs",
|
|
"/v1/admin/interface-center/security-readiness",
|
|
"/v1/admin/data-platform/security/audit-logs",
|
|
"/v1/openapi/data/catalog",
|
|
"/v1/openapi/data/databases/{database_id}/tables/{table_code}/records",
|
|
):
|
|
self.assertIn(path, paths)
|
|
|
|
|
|
class MySQLDatabaseProvisioningTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_system_database_name_is_rejected(self) -> None:
|
|
with patch.object(mysql_service, "_ready", AsyncMock()):
|
|
with self.assertRaisesRegex(ValueError, "系统数据库"):
|
|
await mysql_service.ensure_project_database("mysql", "mysql", "MySQL")
|
|
|
|
async def test_new_database_is_created_without_template_tables(self) -> None:
|
|
connection = _FakeConnection()
|
|
database = {
|
|
"project_id": "empty_demo",
|
|
"tenant_id": "empty_demo",
|
|
"display_name": "Empty Demo",
|
|
"database_name": "empty_demo",
|
|
"schema_name": "empty_demo",
|
|
"engine": "mysql",
|
|
"status": "ready",
|
|
}
|
|
get_connection = Mock(
|
|
side_effect=lambda *_args, **_kwargs: _FakeConnectionContext(connection),
|
|
)
|
|
|
|
with (
|
|
patch.object(mysql_service, "_ready", AsyncMock()),
|
|
patch.object(mysql_service, "get_data_conn", get_connection),
|
|
patch.object(
|
|
mysql_service,
|
|
"list_project_table_definitions",
|
|
AsyncMock(return_value=()),
|
|
),
|
|
patch.object(mysql_service, "_create_physical_table", AsyncMock()) as create_table,
|
|
patch.object(
|
|
mysql_service,
|
|
"get_project_database",
|
|
AsyncMock(return_value=database),
|
|
),
|
|
):
|
|
result = await mysql_service.ensure_project_database(
|
|
"empty_demo",
|
|
"empty_demo",
|
|
"Empty Demo",
|
|
)
|
|
|
|
statements = "\n".join(statement for statement, _params in connection.cursor_instance.executed)
|
|
self.assertEqual(result, database)
|
|
self.assertIn("CREATE DATABASE IF NOT EXISTS `empty_demo`", statements)
|
|
self.assertIn("INSERT INTO project_databases", statements)
|
|
self.assertNotIn("project_table_definitions", statements)
|
|
create_table.assert_not_awaited()
|
|
|
|
|
|
class InterfaceCenterSecuritySummaryTests(unittest.IsolatedAsyncioTestCase):
|
|
async def test_dbeaver_summary_separates_ssh_and_database_endpoints(self) -> None:
|
|
class SummaryCursor:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, _exc_type, _exc, _traceback) -> None:
|
|
return None
|
|
|
|
async def execute(self, _statement: str) -> None:
|
|
return None
|
|
|
|
async def fetchone(self):
|
|
return {
|
|
"active_clients": 0,
|
|
"active_credentials": 0,
|
|
"active_policies": 0,
|
|
"calls_24h": 0,
|
|
"errors_24h": 0,
|
|
}
|
|
|
|
class SummaryConnection:
|
|
def cursor(self):
|
|
return SummaryCursor()
|
|
|
|
with (
|
|
patch.object(interface_service, "_ready", AsyncMock()),
|
|
patch.object(
|
|
interface_service,
|
|
"get_data_conn",
|
|
Mock(return_value=_FakeConnectionContext(SummaryConnection())),
|
|
),
|
|
patch.object(interface_service.settings, "data_mysql_ssh_host", "db.example.com"),
|
|
patch.object(interface_service.settings, "data_mysql_ssh_port", 22),
|
|
patch.object(interface_service.settings, "data_mysql_admin_host", "127.0.0.1"),
|
|
patch.object(interface_service.settings, "data_mysql_admin_port", 3307),
|
|
patch.object(interface_service.settings, "mysql_host_bind", "127.0.0.1"),
|
|
):
|
|
summary = await interface_service.interface_summary()
|
|
|
|
self.assertEqual(summary["ssh_host"], "db.example.com")
|
|
self.assertEqual(summary["ssh_port"], 22)
|
|
self.assertEqual(summary["database_host"], "127.0.0.1")
|
|
self.assertEqual(summary["database_port"], 3307)
|
|
self.assertFalse(summary["mysql_publicly_bound"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|