c054675167
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 18s
CI / build (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 38s
CI / security (pull_request) Successful in 40s
CI / integration_tests (pull_request) Successful in 4m5s
CI / unit_tests (pull_request) Successful in 4m56s
CI / coverage (pull_request) Successful in 5m13s
CI / docker (pull_request) Successful in 1m10s
CI / lint (push) Successful in 13s
CI / quality (push) Successful in 19s
CI / build (push) Successful in 17s
CI / security (push) Successful in 37s
CI / typecheck (push) Successful in 40s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m59s
CI / integration_tests (push) Successful in 3m24s
CI / docker (push) Successful in 40s
CI / coverage (push) Successful in 5m14s
CI / benchmark-publish (push) Successful in 17m55s
CI / benchmark-regression (pull_request) Successful in 34m15s
Implement database resource types (postgres, mysql, sqlite, duckdb) with connection args, auth handling, and transaction-based sandbox strategy using BEGIN/ROLLBACK/COMMIT wrappers. Key changes: - Add DatabaseResourceHandler with 4 database type definitions - Implement TransactionSandbox for transaction_rollback strategy - Wire TransactionSandbox into SandboxFactory - Register database types in bootstrap_builtin_types - Add connection validation with credential masking - Add Behave BDD tests, Robot integration tests, ASV benchmarks ISSUES CLOSED: #342
535 lines
16 KiB
Python
535 lines
16 KiB
Python
"""Step definitions for database resource types and transaction sandbox.
|
|
|
|
Covers:
|
|
- Database type definitions (postgres, mysql, sqlite, duckdb)
|
|
- Connection argument resolution and env var fallback
|
|
- Credential masking in error/validation messages
|
|
- Read-only enforcement
|
|
- Transaction sandbox create/commit/rollback lifecycle
|
|
- SQLite resource handler roundtrip
|
|
- DatabaseResourceHandler protocol conformance
|
|
- SandboxFactory transaction_rollback routing
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sqlite3
|
|
import tempfile
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
|
|
from cleveragents.infrastructure.sandbox.protocol import SandboxStatus
|
|
from cleveragents.infrastructure.sandbox.transaction_sandbox import (
|
|
TransactionSandbox,
|
|
)
|
|
from cleveragents.resource.handlers.database import (
|
|
DATABASE_TYPE_DEFS,
|
|
DatabaseResourceHandler,
|
|
resolve_connection_args,
|
|
validate_connection,
|
|
)
|
|
from cleveragents.resource.handlers.protocol import ResourceHandler
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _find_type_def(name):
|
|
"""Find a database type definition by name."""
|
|
for td in DATABASE_TYPE_DEFS:
|
|
if td["name"] == name:
|
|
return td
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resource Type Definitions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the database type definitions are loaded")
|
|
def given_db_type_defs_loaded(context):
|
|
"""Load database type definitions into context."""
|
|
context.db_type_defs = {td["name"]: td for td in DATABASE_TYPE_DEFS}
|
|
|
|
|
|
@then('the "{name}" type definition should exist')
|
|
def then_type_def_exists(context, name):
|
|
assert name in context.db_type_defs, f"Type '{name}' not found in definitions"
|
|
|
|
|
|
@then('the "{name}" type should have sandbox strategy "{strategy}"')
|
|
def then_type_strategy(context, name, strategy):
|
|
assert context.db_type_defs[name]["sandbox_strategy"] == strategy
|
|
|
|
|
|
@then('the "{name}" type should have a "{arg_name}" cli argument')
|
|
def then_type_has_arg(context, name, arg_name):
|
|
cli_args = context.db_type_defs[name]["cli_args"]
|
|
arg_names = [a["name"] for a in cli_args]
|
|
assert arg_name in arg_names, f"Argument '{arg_name}' not found in {arg_names}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Connection Argument Resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given(
|
|
'postgres connection properties with host "{host}" port {port:d} dbname "{dbname}"'
|
|
)
|
|
def given_pg_props(context, host, port, dbname):
|
|
context.conn_props = {"host": host, "port": port, "dbname": dbname}
|
|
|
|
|
|
@given('postgres properties with connection string "{conn_str}"')
|
|
def given_pg_conn_str(context, conn_str):
|
|
context.conn_props = {"connection-string": conn_str}
|
|
|
|
|
|
@given("postgres properties without explicit credentials")
|
|
def given_pg_no_creds(context):
|
|
context.conn_props = {"host": "localhost", "port": 5432}
|
|
|
|
|
|
@given('sqlite properties with path "{path}"')
|
|
def given_sqlite_props(context, path):
|
|
context.conn_props = {"path": path}
|
|
|
|
|
|
@given("duckdb properties with no path")
|
|
def given_duckdb_no_path(context):
|
|
context.conn_props = {}
|
|
|
|
|
|
@when('I resolve connection args for "{resource_type}"')
|
|
def when_resolve_args(context, resource_type):
|
|
context.resolved_args = resolve_connection_args(context.conn_props, resource_type)
|
|
|
|
|
|
@then('the resolved args should have host "{host}"')
|
|
def then_resolved_host(context, host):
|
|
assert context.resolved_args["host"] == host
|
|
|
|
|
|
@then("the resolved args should have port {port:d}")
|
|
def then_resolved_port(context, port):
|
|
assert context.resolved_args["port"] == port
|
|
|
|
|
|
@then('the resolved args should have dbname "{dbname}"')
|
|
def then_resolved_dbname(context, dbname):
|
|
assert context.resolved_args["dbname"] == dbname
|
|
|
|
|
|
@then('the resolved args should have connection string "{conn_str}"')
|
|
def then_resolved_conn_str(context, conn_str):
|
|
assert context.resolved_args["connection-string"] == conn_str
|
|
|
|
|
|
@then('the resolved args should have user "{user}"')
|
|
def then_resolved_user(context, user):
|
|
args = context.resolved_args
|
|
assert args["user"] == user, f"Expected user '{user}', got '{args['user']}'"
|
|
_cleanup_env(context)
|
|
|
|
|
|
@then('the resolved args should have password "{password}"')
|
|
def then_resolved_password(context, password):
|
|
args = context.resolved_args
|
|
assert args["password"] == password
|
|
_cleanup_env(context)
|
|
|
|
|
|
@then('the resolved args should have path "{path}"')
|
|
def then_resolved_path(context, path):
|
|
assert context.resolved_args["path"] == path
|
|
|
|
|
|
def _cleanup_env(context):
|
|
"""Restore saved environment variables."""
|
|
saved = getattr(context, "_saved_env", {})
|
|
for var, old_value in saved.items():
|
|
if old_value is None:
|
|
os.environ.pop(var, None)
|
|
else:
|
|
os.environ[var] = old_value
|
|
context._saved_env = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Credential Masking
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('postgres connection properties with user "{user}" and password "{password}"')
|
|
def given_pg_with_creds(context, user, password):
|
|
context.conn_props = {
|
|
"host": "localhost",
|
|
"user": user,
|
|
"password": password,
|
|
}
|
|
|
|
|
|
@when('I validate the "{resource_type}" connection')
|
|
def when_validate_connection(context, resource_type):
|
|
resolved = resolve_connection_args(context.conn_props, resource_type)
|
|
success, message = validate_connection(resource_type, resolved)
|
|
context.validation_success = success
|
|
context.validation_message = message
|
|
|
|
|
|
@then('the validation message should not contain "{secret}"')
|
|
def then_message_no_secret(context, secret):
|
|
msg = context.validation_message
|
|
assert secret not in msg, f"Secret '{secret}' found in validation message: {msg}"
|
|
|
|
|
|
@then("the validation result should be successful")
|
|
def then_validation_success(context):
|
|
assert context.validation_success is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Read-Only Enforcement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a temporary SQLite database with a test table")
|
|
def given_temp_sqlite_db(context):
|
|
fd, path = tempfile.mkstemp(suffix=".db")
|
|
os.close(fd)
|
|
conn = sqlite3.connect(path)
|
|
conn.execute("CREATE TABLE test_data (id INTEGER PRIMARY KEY, value TEXT)")
|
|
conn.commit()
|
|
conn.close()
|
|
context.db_path = path
|
|
|
|
|
|
@given("a transaction sandbox in read-only mode")
|
|
def given_ro_sandbox(context):
|
|
sandbox = TransactionSandbox(
|
|
resource_id="res-ro-1",
|
|
original_path=context.db_path,
|
|
read_only=True,
|
|
)
|
|
sandbox.create(plan_id="PLAN-RO")
|
|
context.tx_sandbox = sandbox
|
|
|
|
|
|
@when("I attempt to insert data via the sandbox")
|
|
def when_insert_readonly(context):
|
|
sandbox = context.tx_sandbox
|
|
try:
|
|
sandbox.execute("INSERT INTO test_data (id, value) VALUES (1, 'forbidden')")
|
|
context.write_rejected = False
|
|
except Exception:
|
|
context.write_rejected = True
|
|
finally:
|
|
sandbox.cleanup()
|
|
|
|
|
|
@then("the sandbox should reject the write operation")
|
|
def then_write_rejected(context):
|
|
assert context.write_rejected is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Transaction Sandbox Lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a transaction sandbox for the database")
|
|
def given_tx_sandbox(context):
|
|
sandbox = TransactionSandbox(
|
|
resource_id="res-tx-1",
|
|
original_path=context.db_path,
|
|
)
|
|
context.tx_sandbox = sandbox
|
|
|
|
|
|
@when('I create the sandbox with plan "{plan_id}"')
|
|
def when_create_sandbox(context, plan_id):
|
|
context.tx_sandbox.create(plan_id=plan_id)
|
|
|
|
|
|
@when("I insert a row via the sandbox")
|
|
def when_insert_row(context):
|
|
context.tx_sandbox.execute(
|
|
"INSERT INTO test_data (id, value) VALUES (99, 'test-value')"
|
|
)
|
|
|
|
|
|
@when("I commit the sandbox")
|
|
def when_commit_sandbox(context):
|
|
context.commit_result = context.tx_sandbox.commit("test commit")
|
|
context.tx_sandbox.cleanup()
|
|
|
|
|
|
@when("I rollback the sandbox")
|
|
def when_rollback_sandbox(context):
|
|
context.tx_sandbox.rollback()
|
|
context.tx_sandbox.cleanup()
|
|
|
|
|
|
@when("I cleanup the sandbox")
|
|
def when_cleanup_sandbox(context):
|
|
context.tx_sandbox.cleanup()
|
|
|
|
|
|
@then("the row should be persisted in the database")
|
|
def then_row_persisted(context):
|
|
conn = sqlite3.connect(context.db_path)
|
|
rows = conn.execute("SELECT value FROM test_data WHERE id = 99").fetchall()
|
|
conn.close()
|
|
assert len(rows) == 1, f"Expected 1 row, got {len(rows)}"
|
|
assert rows[0][0] == "test-value"
|
|
|
|
|
|
@then("the row should not be persisted in the database")
|
|
def then_row_not_persisted(context):
|
|
conn = sqlite3.connect(context.db_path)
|
|
rows = conn.execute("SELECT value FROM test_data WHERE id = 99").fetchall()
|
|
conn.close()
|
|
assert len(rows) == 0, f"Expected 0 rows, got {len(rows)}"
|
|
|
|
|
|
@then('the sandbox status should be "{status}"')
|
|
def then_sandbox_status(context, status):
|
|
assert context.tx_sandbox.status == SandboxStatus(status)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SQLite Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a temporary SQLite database")
|
|
def given_temp_sqlite(context):
|
|
fd, path = tempfile.mkstemp(suffix=".db")
|
|
os.close(fd)
|
|
context.sqlite_path = path
|
|
|
|
|
|
@when("I validate the sqlite connection")
|
|
def when_validate_sqlite(context):
|
|
success, msg = validate_connection("sqlite", {"path": context.sqlite_path})
|
|
context.sqlite_valid_ok = success
|
|
context.sqlite_valid_msg = msg
|
|
|
|
|
|
@when("I validate an in-memory sqlite connection")
|
|
def when_validate_sqlite_memory(context):
|
|
success, msg = validate_connection("sqlite", {"path": ":memory:"})
|
|
context.sqlite_valid_ok = success
|
|
context.sqlite_valid_msg = msg
|
|
|
|
|
|
@then("the sqlite validation should succeed")
|
|
def then_sqlite_valid(context):
|
|
assert context.sqlite_valid_ok is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DatabaseResourceHandler Protocol
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a DatabaseResourceHandler instance")
|
|
def given_db_handler(context):
|
|
context.db_handler = DatabaseResourceHandler()
|
|
|
|
|
|
@then("the handler should satisfy the ResourceHandler protocol")
|
|
def then_handler_protocol(context):
|
|
assert isinstance(context.db_handler, ResourceHandler)
|
|
|
|
|
|
@then('the handler default strategy should be "{strategy}"')
|
|
def then_handler_strategy(context, strategy):
|
|
assert context.db_handler._default_strategy.value == strategy
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SandboxFactory transaction_rollback routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when(
|
|
'a sandbox is requested for resource "{res_id}" '
|
|
'at "{path}" using transaction_rollback'
|
|
)
|
|
def when_factory_tx(context, res_id, path):
|
|
context.factory_sandbox = context.sandbox_factory.create_sandbox(
|
|
resource_id=res_id,
|
|
original_path=path,
|
|
sandbox_strategy="transaction_rollback",
|
|
)
|
|
|
|
|
|
@then("the created sandbox should be a TransactionSandbox")
|
|
def then_sandbox_is_tx(context):
|
|
assert isinstance(context.factory_sandbox, TransactionSandbox)
|
|
|
|
|
|
@then('the factory should report "{strategy}" as supported')
|
|
def then_factory_supported(context, strategy):
|
|
assert SandboxFactory.is_supported(strategy) is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TransactionSandbox edge cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I create a TransactionSandbox with empty resource_id")
|
|
def when_tx_empty_res_id(context):
|
|
try:
|
|
TransactionSandbox(resource_id="", original_path="/tmp/x")
|
|
context.edge_error = None
|
|
except ValueError as exc:
|
|
context.edge_error = exc
|
|
|
|
|
|
@when("I create a TransactionSandbox with empty original_path")
|
|
def when_tx_empty_path(context):
|
|
try:
|
|
TransactionSandbox(resource_id="res-1", original_path="")
|
|
context.edge_error = None
|
|
except ValueError as exc:
|
|
context.edge_error = exc
|
|
|
|
|
|
@then('the edge error should be a ValueError mentioning "{fragment}"')
|
|
def then_value_error_with_msg(context, fragment):
|
|
assert context.edge_error is not None, "Expected a ValueError"
|
|
assert isinstance(context.edge_error, ValueError)
|
|
assert fragment in str(context.edge_error)
|
|
|
|
|
|
@when("I try to create the sandbox with an empty plan_id")
|
|
def when_empty_plan_id(context):
|
|
try:
|
|
context.tx_sandbox.create(plan_id="")
|
|
context.edge_error = None
|
|
except ValueError as exc:
|
|
context.edge_error = exc
|
|
|
|
|
|
@then("get_path should return the database path")
|
|
def then_get_path_returns_db(context):
|
|
sandbox = context.tx_sandbox
|
|
result = sandbox.get_path("table")
|
|
assert result == context.db_path
|
|
|
|
|
|
@then("get_path with traversal should raise ValueError")
|
|
def then_get_path_traversal(context):
|
|
sandbox = context.tx_sandbox
|
|
raised = False
|
|
try:
|
|
sandbox.get_path("../../etc/passwd")
|
|
except ValueError:
|
|
raised = True
|
|
assert raised, "Expected ValueError for path traversal"
|
|
|
|
|
|
@then("get_path before create should raise SandboxStateError")
|
|
def then_get_path_wrong_state(context):
|
|
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
|
|
|
sandbox = context.tx_sandbox
|
|
raised = False
|
|
try:
|
|
sandbox.get_path("test")
|
|
except SandboxStateError:
|
|
raised = True
|
|
assert raised, "Expected SandboxStateError"
|
|
|
|
|
|
@then("commit before create should raise SandboxStateError")
|
|
def then_commit_wrong_state(context):
|
|
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
|
|
|
sandbox = context.tx_sandbox
|
|
raised = False
|
|
try:
|
|
sandbox.commit("test")
|
|
except SandboxStateError:
|
|
raised = True
|
|
assert raised, "Expected SandboxStateError"
|
|
|
|
|
|
@then("rollback before create should raise SandboxStateError")
|
|
def then_rollback_wrong_state(context):
|
|
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
|
|
|
sandbox = context.tx_sandbox
|
|
raised = False
|
|
try:
|
|
sandbox.rollback()
|
|
except SandboxStateError:
|
|
raised = True
|
|
assert raised, "Expected SandboxStateError"
|
|
|
|
|
|
@when("I cleanup the sandbox again")
|
|
def when_cleanup_again(context):
|
|
context.tx_sandbox.cleanup()
|
|
|
|
|
|
@then("execute before create should raise SandboxStateError")
|
|
def then_execute_wrong_state(context):
|
|
from cleveragents.infrastructure.sandbox.protocol import SandboxStateError
|
|
|
|
sandbox = context.tx_sandbox
|
|
raised = False
|
|
try:
|
|
sandbox.execute("SELECT 1")
|
|
except SandboxStateError:
|
|
raised = True
|
|
assert raised, "Expected SandboxStateError"
|
|
|
|
|
|
@when('I validate a duckdb connection with path "{path}"')
|
|
def when_validate_duckdb(context, path):
|
|
success, msg = validate_connection("duckdb", {"path": path})
|
|
context.duckdb_valid_ok = success
|
|
context.duckdb_valid_msg = msg
|
|
|
|
|
|
@then("the duckdb validation should succeed with a stub message")
|
|
def then_duckdb_stub(context):
|
|
assert context.duckdb_valid_ok is True
|
|
# Either driver works or stub message about not installed
|
|
assert (
|
|
"DuckDB" in context.duckdb_valid_msg
|
|
or "duckdb" in context.duckdb_valid_msg.lower()
|
|
)
|
|
|
|
|
|
@given('mysql connection properties with host "{host}" and port {port:d}')
|
|
def given_mysql_props(context, host, port):
|
|
context.conn_props = {"host": host, "port": port}
|
|
|
|
|
|
@when("I validate an unknown database type")
|
|
def when_validate_unknown_db(context):
|
|
success, msg = validate_connection("unknown_db", {})
|
|
context.unknown_valid_ok = success
|
|
context.unknown_valid_msg = msg
|
|
|
|
|
|
@then("the unknown validation should fail")
|
|
def then_unknown_fail(context):
|
|
assert context.unknown_valid_ok is False
|
|
assert "Unknown" in context.unknown_valid_msg
|
|
|
|
|
|
@given('mysql properties with connection string "{conn_str}"')
|
|
def given_mysql_conn_str(context, conn_str):
|
|
context.conn_props = {"connection-string": conn_str}
|