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