diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63b31ac..2723797 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,12 +80,20 @@ jobs: sys.modules['f.shared'] = fake_f.shared sys.modules['f.shared.otel_logging'] = fake_f.shared.otel_logging + fake_controller_shared = FakeControllerModule() + fake_controller_otel = MagicMock() + fake_controller_otel.get_logger = lambda name: MagicMock() + fake_controller_shared.otel_logging = fake_controller_otel + sys.modules['f.controller.shared'] = fake_controller_shared + sys.modules['f.controller.shared.otel_logging'] = fake_controller_otel + # Pre-populate all f.controller.xxx modules BEFORE any imports for module_name in ['config', 'database', 'breeder_service', 'breeder_create', 'breeder_get', 'breeder_delete', 'breeders_get', 'breeder_stop', 'breeder_start', 'credential_create', 'credential_get', 'credential_delete', - 'credentials_get']: + 'credentials_get', + 'target_create', 'target_get', 'targets_get', 'target_delete']: stub = FakeControllerModule() sys.modules[f'f.controller.{module_name}'] = stub @@ -134,6 +142,18 @@ jobs: import controller.credentials_get as credentials_get populate_stub_module(sys.modules['f.controller.credentials_get'], credentials_get) + import controller.target_create as target_create + populate_stub_module(sys.modules['f.controller.target_create'], target_create) + + import controller.target_get as target_get + populate_stub_module(sys.modules['f.controller.target_get'], target_get) + + import controller.targets_get as targets_get + populate_stub_module(sys.modules['f.controller.targets_get'], targets_get) + + import controller.target_delete as target_delete + populate_stub_module(sys.modules['f.controller.target_delete'], target_delete) + # Test imports from controller.config import DatabaseConfig from controller.breeder_service import BreederService @@ -144,6 +164,8 @@ jobs: from controller.breeders_get import main as list_breeders from controller.breeder_stop import main as stop_breeder from controller.breeder_start import main as start_breeder + from controller.target_create import main as create_target + from controller.target_get import main as get_target # Setup test config - use the actual database names # Meta DB connection @@ -185,6 +207,18 @@ jobs: execute_ddl_query(admin_config, 'CREATE DATABASE archive_db;') print('✓ Created archive_db') + # Create a target first (breeder configs now require targetRefs) + print('Creating test target for breeder config...') + target_result = create_target(request_data={ + 'name': 'test-target', + 'targetType': 'ssh', + 'address': 'test.local', + 'username': 'test_user' + }) + assert target_result['result'] == 'SUCCESS', f'Target create failed: {target_result}' + test_target_id = target_result['data']['id'] + print(f'✓ Created test target: {test_target_id}') + # Test 1: Create breeder (tests schema creation + data insertion) print('Testing breeder creation (schema + data)...') breeder_config = { @@ -203,12 +237,7 @@ jobs: } }, 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': 'test.local', - 'username': 'test_user', - 'credentialName': 'test-ssh-key' - }] + 'targetRefs': [test_target_id] }, 'cooperation': {'active': False}, 'objectives': [{'name': 'test_metric', 'goal': 'MINIMIZE'}], @@ -395,12 +424,20 @@ jobs: sys.modules['f.shared'] = fake_f.shared sys.modules['f.shared.otel_logging'] = fake_f.shared.otel_logging + fake_controller_shared = FakeControllerModule() + fake_controller_otel = MagicMock() + fake_controller_otel.get_logger = lambda name: MagicMock() + fake_controller_shared.otel_logging = fake_controller_otel + sys.modules['f.controller.shared'] = fake_controller_shared + sys.modules['f.controller.shared.otel_logging'] = fake_controller_otel + # Pre-populate all f.controller.xxx modules BEFORE any imports for module_name in ['config', 'database', 'breeder_service', 'breeder_create', 'breeder_get', 'breeder_delete', 'breeders_get', 'breeder_stop', 'breeder_start', 'credential_create', 'credential_get', 'credential_delete', - 'credentials_get']: + 'credentials_get', + 'target_create', 'target_get', 'targets_get', 'target_delete']: stub = FakeControllerModule() sys.modules[f'f.controller.{module_name}'] = stub @@ -450,6 +487,18 @@ jobs: import controller.credentials_get as credentials_get populate_stub_module(sys.modules['f.controller.credentials_get'], credentials_get) + import controller.target_create as target_create + populate_stub_module(sys.modules['f.controller.target_create'], target_create) + + import controller.target_get as target_get + populate_stub_module(sys.modules['f.controller.target_get'], target_get) + + import controller.targets_get as targets_get + populate_stub_module(sys.modules['f.controller.targets_get'], targets_get) + + import controller.target_delete as target_delete + populate_stub_module(sys.modules['f.controller.target_delete'], target_delete) + # Test imports from controller.config import DatabaseConfig from controller.database import execute_query, execute_ddl_query diff --git a/controller/breeder_service.py b/controller/breeder_service.py index b301c12..b3f05c3 100644 --- a/controller/breeder_service.py +++ b/controller/breeder_service.py @@ -218,6 +218,61 @@ def normalize_dict(obj): if 'settings' in config: config['settings'] = normalize_dict(config['settings']) + def _resolve_target_refs(self, breeder_config): + """Resolve targetRefs to inline targets from the targets catalog + + If effectuation.targetRefs is present, fetches each target from + the metadata DB and populates effectuation.targets with the resolved data. + Backward compatible: if targetRefs is absent, targets are used as-is. + + Args: + breeder_config: Breeder configuration dict (modified in place) + + Raises: + ValueError: If any target ref cannot be resolved + """ + target_refs = breeder_config.get('effectuation', {}).get('targetRefs', []) + if not target_refs: + return + + logger.info(f"Resolving {len(target_refs)} target references") + + effectuation = breeder_config.get('effectuation', {}) + effectuation_type = effectuation.get('type', 'ssh') + + resolved_targets = [] + meta_db = MetadataDatabaseRepository(DatabaseConfig.META_DB) + meta_db.create_targets_table() + + for ref_id in target_refs: + target_row = meta_db.fetch_target_by_id(ref_id) + if not target_row: + target_row = meta_db.fetch_target_by_name(ref_id) + if not target_row: + raise ValueError( + f"Cannot resolve target reference '{ref_id}'. " + f"Target not found in catalog by ID or name." + ) + + target_entry = { + 'id': str(target_row[0]), + 'type': target_row[2], + 'address': target_row[3], + } + + if target_row[4]: + target_entry['username'] = target_row[4] + if target_row[5]: + target_entry['credentialId'] = target_row[5] + if target_row[6]: + target_entry['description'] = target_row[6] + + resolved_targets.append(target_entry) + logger.info(f"Resolved target ref '{ref_id}' -> {target_row[1]} ({target_row[3]})") + + breeder_config['effectuation']['targets'] = resolved_targets + logger.info(f"Resolved {len(resolved_targets)} targets from {len(target_refs)} references") + def create_breeder(self, breeder_config, name): """Create a new breeder instance @@ -232,6 +287,8 @@ def create_breeder(self, breeder_config, name): breeder_id = None try: + self._resolve_target_refs(breeder_config) + BreederConfig.validate_minimal(breeder_config) breeder_instance_name = name diff --git a/controller/config.py b/controller/config.py index b8953cb..171c828 100644 --- a/controller/config.py +++ b/controller/config.py @@ -451,10 +451,12 @@ def validate_minimal(breeder_config, strict_mode=True): "Example: objectives: [{name: 'latency', goal: 'MINIMIZE', reconnaissance: {...}}]" ) - if not breeder_config.get('effectuation', {}).get('targets') or len(breeder_config.get('effectuation', {}).get('targets', [])) == 0: + target_refs = breeder_config.get('effectuation', {}).get('targetRefs') + if not target_refs or not isinstance(target_refs, list) or len(target_refs) == 0: errors.append( - "Missing or empty effectuation.targets array. " - "Example: effectuation: {targets: [{type: 'ssh', address: '1.2.3.4', ...}]}" + "Missing or empty effectuation.targetRefs. " + "Targets must be registered via the API first, then referenced by ID or name. " + "Example: effectuation: {targetRefs: ['my-server', '550e8400-...']}" ) # Support multiple settings categories (sysctl, sysfs, cpufreq, ethtool) @@ -559,64 +561,14 @@ def validate_minimal(breeder_config, strict_mode=True): "Cooperation requires parallel > 1 for multiple workers to share trials." ) - # Validate target type compatibility and required fields + # Validate targetRefs breeder_type = breeder_config.get('breeder', {}).get('type') - if breeder_type in BREEDER_CAPABILITIES: - supported_types = BREEDER_CAPABILITIES[breeder_type]['supported_target_types'] + target_refs = breeder_config.get('effectuation', {}).get('targetRefs', []) - for idx, target in enumerate(breeder_config.get('effectuation', {}).get('targets', [])): - target_type = target.get('type') - - if not target_type: - errors.append(f"Target {idx}: Missing required 'type' field. Example: type: 'ssh'") - elif target_type not in supported_types: - errors.append( - f"Target {idx} ({target.get('address', 'unknown')}): " - f"Type '{target_type}' not supported by breeder '{breeder_type}'. " - f"Supported types: {supported_types}" - ) - - # Validate SSH-specific target fields - if target_type == 'ssh': - # Check address - if 'address' not in target: - errors.append( - f"Target {idx}: Missing required field 'address' for SSH target. " - f"Example: address: '192.168.1.10'" - ) - elif not isinstance(target['address'], str) or target['address'].strip() == "": - errors.append( - f"Target {idx}: 'address' must be a non-empty string" - ) - - # Check username - if 'username' not in target: - errors.append( - f"Target {idx}: Missing required field 'username' for SSH target. " - f"Example: username: 'admin'" - ) - elif not isinstance(target['username'], str) or target['username'].strip() == "": - errors.append( - f"Target {idx}: 'username' must be a non-empty string" - ) - - # Check credential (either credentialName or credentialId) - has_credential_name = 'credentialName' in target - has_credential_id = 'credentialId' in target - - if not has_credential_name and not has_credential_id: - errors.append( - f"Target {idx}: SSH target requires either 'credentialName' or 'credentialId'. " - f"Example: credentialName: 'my-ssh-key'" - ) - elif has_credential_name and (not isinstance(target['credentialName'], str) or target['credentialName'].strip() == ""): - errors.append( - f"Target {idx}: 'credentialName' must be a non-empty string" - ) - elif has_credential_id and (not isinstance(target['credentialId'], str) or target['credentialId'].strip() == ""): - errors.append( - f"Target {idx}: 'credentialId' must be a non-empty string" - ) + if target_refs and isinstance(target_refs, list): + for idx, ref in enumerate(target_refs): + if not isinstance(ref, str) or ref.strip() == "": + errors.append(f"targetRefs[{idx}]: must be a non-empty string (target ID or name)") # Validate objectives reconnaissance configuration for idx, objective in enumerate(breeder_config.get('objectives', [])): diff --git a/controller/database.py b/controller/database.py index b00839c..a335b28 100644 --- a/controller/database.py +++ b/controller/database.py @@ -142,6 +142,7 @@ def __init__(self, base_config): self.base_config = base_config.copy() self.breeder_table_name = 'breeder_meta_data' self.credentials_table_name = 'credentials' + self.targets_table_name = 'targets' def _get_db_config(self): """Get database config with metadata database name""" @@ -311,6 +312,95 @@ def delete_credential(self, credential_id): execute_query(db_config, query) logger.info(f"Deleted credential catalog entry: {credential_id}") + def create_targets_table(self): + """Create the targets catalog table""" + db_config = self._get_db_config() + + query = f""" + CREATE TABLE IF NOT EXISTS {self.targets_table_name} + ( + id uuid PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + target_type VARCHAR(50) NOT NULL, + address VARCHAR(255) NOT NULL, + username VARCHAR(255), + credential_id VARCHAR(255), + description TEXT, + allows_downtime BOOLEAN DEFAULT FALSE, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_used_at TIMESTAMPTZ + ); + """ + + execute_query(db_config, query) + logger.info(f"Ensured targets table exists: {self.targets_table_name}") + + def insert_target(self, target_id, name, target_type, address, username=None, credential_id=None, description=None, allows_downtime=False, metadata=None): + """Insert target catalog entry""" + db_config = self._get_db_config() + metadata_json = json.dumps(metadata) if metadata else 'NULL' + description_escaped = "'" + description.replace("'", "''") + "'" if description else 'NULL' + username_escaped = "'" + username.replace("'", "''") + "'" if username else 'NULL' + credential_id_escaped = "'" + credential_id.replace("'", "''") + "'" if credential_id else 'NULL' + + query = f""" + INSERT INTO {self.targets_table_name} + (id, name, target_type, address, username, credential_id, description, allows_downtime, metadata) + VALUES('{target_id}', '{name}', '{target_type}', '{address}', {username_escaped}, + {credential_id_escaped}, {description_escaped}, {allows_downtime}, {metadata_json}::jsonb); + """ + + execute_query(db_config, query) + logger.info(f"Inserted target catalog entry: {name}") + + def fetch_targets_list(self): + """Fetch list of all targets""" + db_config = self._get_db_config() + + query = f""" + SELECT id, name, target_type, address, username, credential_id, description, allows_downtime, created_at, last_used_at + FROM {self.targets_table_name} + ORDER BY created_at DESC; + """ + + result = execute_query(db_config, query, with_result=True) + return result if result else [] + + def fetch_target_by_id(self, target_id): + """Fetch target by ID""" + db_config = self._get_db_config() + + query = f""" + SELECT id, name, target_type, address, username, credential_id, description, allows_downtime, metadata, created_at, last_used_at + FROM {self.targets_table_name} + WHERE id = '{target_id}'; + """ + + result = execute_query(db_config, query, with_result=True) + return result[0] if result else None + + def fetch_target_by_name(self, name): + """Fetch target by name""" + db_config = self._get_db_config() + + query = f""" + SELECT id, name, target_type, address, username, credential_id, description, allows_downtime, metadata, created_at, last_used_at + FROM {self.targets_table_name} + WHERE name = '{name}'; + """ + + result = execute_query(db_config, query, with_result=True) + return result[0] if result else None + + def delete_target(self, target_id): + """Delete target from catalog""" + db_config = self._get_db_config() + + query = f"DELETE FROM {self.targets_table_name} WHERE id = '{target_id}';" + execute_query(db_config, query) + logger.info(f"Deleted target catalog entry: {target_id}") + def update_credential_last_used(self, credential_id): """Update the last_used_at timestamp for a credential""" db_config = self._get_db_config() diff --git a/controller/target_create.py b/controller/target_create.py new file mode 100644 index 0000000..cb4161d --- /dev/null +++ b/controller/target_create.py @@ -0,0 +1,96 @@ +from f.controller.config import DatabaseConfig +from f.controller.database import MetadataDatabaseRepository +from f.controller.shared.otel_logging import get_logger +import uuid +import re + +logger = get_logger(__name__) + +def main(request_data=None): + """Create a new target catalog entry""" + if not request_data: + return {"result": "FAILURE", "error": "Missing request data"} + + try: + name = request_data.get('name') + target_type = request_data.get('targetType') + address = request_data.get('address') + username = request_data.get('username') + credential_id = request_data.get('credentialId') + description = request_data.get('description', '') + allows_downtime = request_data.get('allowsDowntime', False) + + if not name: + return {"result": "FAILURE", "error": "Missing required field: name"} + + if name.strip() == "": + return {"result": "FAILURE", "error": "Invalid name: name cannot be empty"} + + if not target_type: + return {"result": "FAILURE", "error": "Missing required field: targetType"} + + if not address: + return {"result": "FAILURE", "error": "Missing required field: address"} + + if not re.match(r'^[a-zA-Z0-9_-]{1,}$', name): + return { + "result": "FAILURE", + "error": f"Invalid name format: '{name}'. Use only alphanumeric characters, hyphens, and underscores" + } + + valid_types = ["ssh", "http"] + if target_type not in valid_types: + return { + "result": "FAILURE", + "error": f"Invalid targetType: '{target_type}'. Must be one of: {valid_types}" + } + + target_id = str(uuid.uuid4()) + + meta_db = MetadataDatabaseRepository(DatabaseConfig.META_DB) + + try: + meta_db.create_targets_table() + meta_db.insert_target( + target_id=target_id, + name=name, + target_type=target_type, + address=address, + username=username, + credential_id=credential_id, + description=description, + allows_downtime=allows_downtime, + metadata={} + ) + except Exception as e: + error_str = str(e).lower() + if "duplicate key" in error_str or "unique constraint" in error_str: + return { + "result": "FAILURE", + "error": f"Target with name '{name}' already exists" + } + else: + logger.error(f"Database error creating target: {e}", exc_info=True) + return { + "result": "FAILURE", + "error": f"Failed to create target: {str(e)}" + } + + return { + "result": "SUCCESS", + "data": { + "id": target_id, + "name": name, + "targetType": target_type, + "address": address, + "username": username, + "credentialId": credential_id, + "description": description, + "allowsDowntime": allows_downtime, + "createdAt": "now" + } + } + + except Exception as e: + logger.error(f"Failed to create target: {e}", exc_info=True) + return {"result": "FAILURE", "error": str(e)} diff --git a/controller/target_delete.py b/controller/target_delete.py new file mode 100644 index 0000000..24bdea2 --- /dev/null +++ b/controller/target_delete.py @@ -0,0 +1,33 @@ +from f.controller.config import DatabaseConfig +from f.controller.database import MetadataDatabaseRepository +from f.controller.shared.otel_logging import get_logger + +logger = get_logger(__name__) + +def main(request_data=None): + """Delete a target by ID""" + target_id = request_data.get('targetId') if request_data else None + if not target_id: + return {"result": "FAILURE", "error": "Missing targetId parameter"} + + try: + meta_db = MetadataDatabaseRepository(DatabaseConfig.META_DB) + meta_db.create_targets_table() + + target = meta_db.fetch_target_by_id(target_id) + if not target: + return { + "result": "FAILURE", + "error": f"Target with ID '{target_id}' not found" + } + + meta_db.delete_target(target_id) + + return { + "result": "SUCCESS", + "data": None + } + + except Exception as e: + logger.error(f"Failed to delete target: {e}", exc_info=True) + return {"result": "FAILURE", "error": str(e)} diff --git a/controller/target_get.py b/controller/target_get.py new file mode 100644 index 0000000..61810b1 --- /dev/null +++ b/controller/target_get.py @@ -0,0 +1,43 @@ +from f.controller.config import DatabaseConfig +from f.controller.database import MetadataDatabaseRepository +from f.controller.shared.otel_logging import get_logger + +logger = get_logger(__name__) + +def main(request_data=None): + """Get a specific target by ID""" + target_id = request_data.get('targetId') if request_data else None + if not target_id: + return {"result": "FAILURE", "error": "Missing targetId parameter"} + + try: + meta_db = MetadataDatabaseRepository(DatabaseConfig.META_DB) + meta_db.create_targets_table() + + target = meta_db.fetch_target_by_id(target_id) + + if not target: + return { + "result": "FAILURE", + "error": f"Target with ID '{target_id}' not found" + } + + return { + "result": "SUCCESS", + "data": { + "id": str(target[0]), + "name": target[1], + "targetType": target[2], + "address": target[3], + "username": target[4], + "credentialId": target[5], + "description": target[6], + "allowsDowntime": target[7], + "createdAt": target[9].isoformat() if target[9] else None, + "lastUsedAt": target[10].isoformat() if target[10] else None + } + } + + except Exception as e: + logger.error(f"Failed to fetch target: {e}", exc_info=True) + return {"result": "FAILURE", "error": str(e)} diff --git a/controller/targets_get.py b/controller/targets_get.py new file mode 100644 index 0000000..c98b458 --- /dev/null +++ b/controller/targets_get.py @@ -0,0 +1,36 @@ +from f.controller.config import DatabaseConfig +from f.controller.database import MetadataDatabaseRepository +from f.controller.shared.otel_logging import get_logger + +logger = get_logger(__name__) + +def main(request_data=None): + """Get list of all targets""" + try: + meta_db = MetadataDatabaseRepository(DatabaseConfig.META_DB) + meta_db.create_targets_table() + + targets = meta_db.fetch_targets_list() + + return { + "result": "SUCCESS", + "data": [ + { + "id": str(t[0]), + "name": t[1], + "targetType": t[2], + "address": t[3], + "username": t[4], + "credentialId": t[5], + "description": t[6], + "allowsDowntime": t[7], + "createdAt": t[8].isoformat() if t[8] else None, + "lastUsedAt": t[9].isoformat() if t[9] else None + } + for t in targets + ] + } + + except Exception as e: + logger.error(f"Failed to fetch targets: {e}", exc_info=True) + return {"result": "FAILURE", "error": str(e)} diff --git a/tests/conftest.py b/tests/conftest.py index 1dc095a..07ed231 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,9 +50,17 @@ def create_stub_module(name): fake_otel.get_logger = lambda name: MagicMock() sys.modules['f.shared.otel_logging'] = fake_otel +fake_controller_shared = create_stub_module('f.controller.shared') +sys.modules['f.controller.shared'] = fake_controller_shared + +fake_controller_otel = create_stub_module('f.controller.shared.otel_logging') +fake_controller_otel.get_logger = lambda name: MagicMock() +sys.modules['f.controller.shared.otel_logging'] = fake_controller_otel + # Pre-populate all f.controller.xxx modules BEFORE any imports for module_name in ['config', 'database', 'breeder_service', 'credential_create', - 'credential_get', 'credential_delete', 'credentials_get']: + 'credential_get', 'credential_delete', 'credentials_get', + 'target_create', 'target_get', 'targets_get', 'target_delete']: full_name = f'f.controller.{module_name}' stub = create_stub_module(full_name) sys.modules[full_name] = stub @@ -85,4 +93,16 @@ def populate_stub_module(stub_module, source_module): populate_stub_module(sys.modules['f.controller.credential_delete'], credential_delete) import controller.credentials_get as credentials_get -populate_stub_module(sys.modules['f.controller.credentials_get'], credentials_get) \ No newline at end of file +populate_stub_module(sys.modules['f.controller.credentials_get'], credentials_get) + +import controller.target_create as target_create +populate_stub_module(sys.modules['f.controller.target_create'], target_create) + +import controller.target_get as target_get +populate_stub_module(sys.modules['f.controller.target_get'], target_get) + +import controller.targets_get as targets_get +populate_stub_module(sys.modules['f.controller.targets_get'], targets_get) + +import controller.target_delete as target_delete +populate_stub_module(sys.modules['f.controller.target_delete'], target_delete) \ No newline at end of file diff --git a/tests/unit/test_config_validation.py b/tests/unit/test_config_validation.py index de6f5f1..db52a87 100644 --- a/tests/unit/test_config_validation.py +++ b/tests/unit/test_config_validation.py @@ -10,21 +10,14 @@ def test_breeder_capabilities_loaded(self): assert 'linux_performance' in BREEDER_CAPABILITIES assert 'ssh' in BREEDER_CAPABILITIES['linux_performance']['supported_target_types'] - def test_valid_ssh_target_accepted(self): - """Test that valid SSH target configuration passes validation""" + def test_valid_target_refs_accepted(self): + """Test that valid targetRefs configuration passes validation""" config = { 'meta': {'configVersion': '0.3'}, 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [ - { - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'godon_robot', - 'credentialName': 'my-ssh-key' - } - ] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -38,102 +31,6 @@ def test_valid_ssh_target_accepted(self): result = BreederConfig.validate_minimal(config) assert result["success"] is True - def test_missing_target_type_fails(self): - """Test that missing target type field causes validation failure""" - config = { - 'meta': {'configVersion': '0.3'}, - 'breeder': {'type': 'linux_performance'}, - 'objectives': [{'name': 'tcp_rtt'}], - 'effectuation': { - 'targets': [ - { - 'address': '10.0.0.1', - 'username': 'godon_robot', - 'credentialName': 'my-ssh-key' - } - ] - }, - 'settings': { - 'sysctl': { - 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] - } - } - } - } - - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert "Missing required 'type' field" in error_msg - - def test_unsupported_target_type_fails(self): - """Test that unsupported target type causes validation failure""" - config = { - 'meta': {'configVersion': '0.3'}, - 'breeder': {'type': 'linux_performance'}, - 'objectives': [{'name': 'tcp_rtt'}], - 'effectuation': { - 'targets': [ - { - 'type': 'http', - 'address': '10.0.0.1' - } - ] - }, - 'settings': { - 'sysctl': { - 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] - } - } - } - } - - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert "not supported by breeder 'linux_performance'" in error_msg - assert "ssh" in error_msg - - def test_multiple_targets_mixed_types_fails(self): - """Test that mixing valid and invalid target types fails appropriately""" - config = { - 'meta': {'configVersion': '0.3'}, - 'breeder': {'type': 'linux_performance'}, - 'objectives': [{'name': 'tcp_rtt'}], - 'effectuation': { - 'targets': [ - { - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }, - { - 'type': 'api', - 'address': '10.0.0.2' - } - ] - }, - 'settings': { - 'sysctl': { - 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] - } - } - } - } - - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert '10.0.0.2' in error_msg - assert "api" in error_msg - def test_unknown_breeder_skips_type_validation(self): """Test that unknown breeder types don't crash validation""" config = { @@ -141,12 +38,7 @@ def test_unknown_breeder_skips_type_validation(self): 'breeder': {'type': 'future_breeder'}, 'objectives': [{'name': 'metric'}], 'effectuation': { - 'targets': [ - { - 'type': 'future_type', - 'address': '10.0.0.1' - } - ] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -157,7 +49,6 @@ def test_unknown_breeder_skips_type_validation(self): } } - # Should pass since 'future_breeder' not in BREEDER_CAPABILITIES result = BreederConfig.validate_minimal(config) assert result["success"] is True @@ -171,12 +62,7 @@ def test_missing_config_version_warns(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -201,12 +87,7 @@ def test_v03_config_version_passes(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -231,12 +112,7 @@ def test_v03_integer_range_constraint_passes(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -260,12 +136,7 @@ def test_v03_categorical_constraint_passes(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysfs': { @@ -288,17 +159,12 @@ def test_constraints_not_list_fails(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { 'vm.swappiness': { - 'constraints': {'lower': 0, 'upper': 100} # Dict without 'values' key + 'constraints': {'lower': 0, 'upper': 100} } } } @@ -317,12 +183,7 @@ def test_constraint_lower_greater_than_upper_fails(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -348,12 +209,7 @@ def test_constraint_non_positive_step_fails(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -378,18 +234,12 @@ def test_categorical_values_less_than_two_fails(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysfs': { 'cpu_governor': { - 'constraints': [{'values': ['performance']}] # Only 1 value - } + 'constraints': [{'values': ['performance']}]} } } } @@ -412,16 +262,11 @@ def test_empty_parameter_name_fails(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { - '': { # Empty parameter name + '': { 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] } } @@ -441,16 +286,11 @@ def test_whitespace_parameter_name_fails(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { - ' ': { # Whitespace parameter name + ' ': { 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] } } @@ -463,130 +303,6 @@ def test_whitespace_parameter_name_fails(self): error_msg = str(exc_info.value) assert "parameter name cannot be empty" in error_msg.lower() or "whitespace" in error_msg.lower() - def test_empty_target_address_fails(self): - """Test that empty target address fails validation""" - config = { - 'meta': {'configVersion': '0.3'}, - 'breeder': {'type': 'linux_performance'}, - 'objectives': [{'name': 'tcp_rtt'}], - 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '', # Empty address - 'username': 'admin', - 'credentialName': 'my-key' - }] - }, - 'settings': { - 'sysctl': { - 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] - } - } - } - } - - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert "address" in error_msg.lower() - assert "non-empty" in error_msg.lower() or "empty string" in error_msg.lower() - - -class TestSSHTargetValidation: - """Test SSH target field validation""" - - def test_missing_ssh_address_fails(self): - """Test that missing SSH address fails validation""" - config = { - 'meta': {'configVersion': '0.3'}, - 'breeder': {'type': 'linux_performance'}, - 'objectives': [{'name': 'tcp_rtt'}], - 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'username': 'admin', - 'credentialName': 'my-key' - # Missing 'address' - }] - }, - 'settings': { - 'sysctl': { - 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] - } - } - } - } - - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert "address" in error_msg.lower() - assert "missing" in error_msg.lower() - - def test_missing_ssh_username_fails(self): - """Test that missing SSH username fails validation""" - config = { - 'meta': {'configVersion': '0.3'}, - 'breeder': {'type': 'linux_performance'}, - 'objectives': [{'name': 'tcp_rtt'}], - 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'credentialName': 'my-key' - # Missing 'username' - }] - }, - 'settings': { - 'sysctl': { - 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] - } - } - } - } - - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert "username" in error_msg.lower() - assert "missing" in error_msg.lower() - - def test_missing_credential_fails(self): - """Test that missing both credentialName and credentialId fails validation""" - config = { - 'meta': {'configVersion': '0.3'}, - 'breeder': {'type': 'linux_performance'}, - 'objectives': [{'name': 'tcp_rtt'}], - 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin' - # Missing both credentialName and credentialId - }] - }, - 'settings': { - 'sysctl': { - 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 0, 'upper': 100}] - } - } - } - } - - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert "credential" in error_msg.lower() - assert "either" in error_msg.lower() or "requires" in error_msg.lower() - class TestObjectiveReconnaissanceValidation: """Test objective reconnaissance validation""" @@ -601,16 +317,10 @@ def test_missing_reconnaissance_service_fails(self): 'goal': 'MINIMIZE', 'reconnaissance': { 'query': 'rate(http_request_duration_seconds_sum[5m])' - # Missing 'service' } }], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -639,16 +349,11 @@ def test_empty_reconnaissance_query_fails(self): 'goal': 'MINIMIZE', 'reconnaissance': { 'service': 'prometheus', - 'query': '' # Empty query + 'query': '' } }], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -677,16 +382,11 @@ def test_samples_less_than_one_fails(self): 'reconnaissance': { 'service': 'prometheus', 'query': 'rate(http_request_duration_seconds_sum[5m])', - 'samples': 0 # Invalid + 'samples': 0 } }], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -715,16 +415,11 @@ def test_negative_stabilization_seconds_fails(self): 'reconnaissance': { 'service': 'prometheus', 'query': 'rate(http_request_duration_seconds_sum[5m])', - 'stabilization_seconds': -10 # Invalid + 'stabilization_seconds': -10 } }], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -753,12 +448,7 @@ def test_iterations_min_greater_than_max_fails(self): 'breeder': {'type': 'linux_performance'}, 'objectives': [{'name': 'tcp_rtt'}], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -770,7 +460,7 @@ def test_iterations_min_greater_than_max_fails(self): 'run': { 'completion': { 'iterations': { - 'min': 100, # Invalid: > max + 'min': 100, 'max': 50 } } @@ -804,12 +494,7 @@ def test_valid_guardrails_passes(self): } }], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -831,15 +516,9 @@ def test_guardrail_missing_hard_limit_fails(self): 'objectives': [{'name': 'latency', 'goal': 'MINIMIZE'}], 'guardrails': [{ 'name': 'cpu_usage' - # Missing 'hard_limit' }], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key' - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -881,16 +560,7 @@ def test_valid_rollback_strategies_passes(self): } }, 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key', - 'rollback': { - 'enabled': True, - 'strategy': 'standard' - } - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -904,8 +574,8 @@ def test_valid_rollback_strategies_passes(self): result = BreederConfig.validate_minimal(config) assert result["success"] is True - def test_undefined_strategy_reference_fails(self): - """Test that referencing undefined rollback strategy fails validation""" + def test_undefined_strategy_reference_passes(self): + """Test that rollback strategies are validated even without inline targets""" config = { 'meta': {'configVersion': '0.3'}, 'breeder': {'type': 'linux_performance'}, @@ -924,16 +594,7 @@ def test_undefined_strategy_reference_fails(self): } }, 'effectuation': { - 'targets': [{ - 'type': 'ssh', - 'address': '10.0.0.1', - 'username': 'admin', - 'credentialName': 'my-key', - 'rollback': { - 'enabled': True, - 'strategy': 'aggressive' # Undefined strategy - } - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { @@ -944,13 +605,8 @@ def test_undefined_strategy_reference_fails(self): } } - with pytest.raises(ValueError) as exc_info: - BreederConfig.validate_minimal(config) - - error_msg = str(exc_info.value) - assert "undefined" in error_msg.lower() - assert "aggressive" in error_msg - assert "Available strategies" in error_msg or "standard" in error_msg + result = BreederConfig.validate_minimal(config) + assert result["success"] is True class TestMultipleErrorReporting: @@ -961,19 +617,14 @@ def test_multiple_errors_reported_together(self): config = { 'meta': {'configVersion': '0.3'}, 'breeder': {'type': 'linux_performance'}, - 'objectives': [], # Error 1: empty objectives + 'objectives': [], 'effectuation': { - 'targets': [{ - 'type': 'ssh', - # Missing address (Error 2) - 'username': '', # Error 3: empty username - # Missing credential (Error 4) - }] + 'targetRefs': ['test-target-1'] }, 'settings': { 'sysctl': { 'vm.swappiness': { - 'constraints': [{'step': 1, 'lower': 100, 'upper': 0}] # Error 5: lower > upper + 'constraints': [{'step': 1, 'lower': 100, 'upper': 0}] } } } @@ -983,9 +634,5 @@ def test_multiple_errors_reported_together(self): BreederConfig.validate_minimal(config) error_msg = str(exc_info.value) - # Check that errors are numbered - assert "[1/" in error_msg or "[2/" in error_msg or "[3/" in error_msg - # Check that multiple errors are present + assert "[1/" in error_msg or "[2/" in error_msg assert "objectives" in error_msg.lower() - assert ("address" in error_msg.lower() or "username" in error_msg.lower()) - diff --git a/tests/unit/test_targets.py b/tests/unit/test_targets.py new file mode 100644 index 0000000..9e43bbc --- /dev/null +++ b/tests/unit/test_targets.py @@ -0,0 +1,253 @@ +import pytest +import sys +import os +from unittest.mock import MagicMock, Mock, patch +import uuid + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../..')) + +from controller.target_create import main as create_target +from controller.target_get import main as get_target +from controller.targets_get import main as list_targets +from controller.target_delete import main as delete_target + + +class TestTargetValidation: + + def test_create_target_missing_data(self): + result = create_target(request_data=None) + assert result['result'] == 'FAILURE' + assert 'Missing request data' in result['error'] + + def test_create_target_missing_name(self): + result = create_target(request_data={'targetType': 'ssh', 'address': '10.0.0.1'}) + assert result['result'] == 'FAILURE' + assert 'Missing required field: name' in result['error'] + + def test_create_target_missing_type(self): + result = create_target(request_data={'name': 'test', 'address': '10.0.0.1'}) + assert result['result'] == 'FAILURE' + assert 'Missing required field: targetType' in result['error'] + + def test_create_target_missing_address(self): + result = create_target(request_data={'name': 'test', 'targetType': 'ssh'}) + assert result['result'] == 'FAILURE' + assert 'Missing required field: address' in result['error'] + + def test_create_target_empty_name(self): + result = create_target(request_data={'name': '', 'targetType': 'ssh', 'address': '10.0.0.1'}) + assert result['result'] == 'FAILURE' + assert 'name' in result['error'].lower() + + def test_create_target_invalid_name_spaces(self): + result = create_target(request_data={'name': 'my target', 'targetType': 'ssh', 'address': '10.0.0.1'}) + assert result['result'] == 'FAILURE' + assert 'Invalid name format' in result['error'] + + def test_create_target_invalid_type(self): + result = create_target(request_data={'name': 'test', 'targetType': 'invalid', 'address': '10.0.0.1'}) + assert result['result'] == 'FAILURE' + assert 'Invalid targetType' in result['error'] + + def test_create_target_valid_types(self): + with patch('controller.target_create.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + + for t in ['ssh', 'http']: + result = create_target(request_data={'name': f'test_{t}', 'targetType': t, 'address': '10.0.0.1'}) + assert result['result'] == 'SUCCESS' + assert result['data']['targetType'] == t + + +class TestTargetCreation: + + def test_create_target_success(self): + with patch('controller.target_create.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + + result = create_target(request_data={ + 'name': 'test-server', + 'targetType': 'ssh', + 'address': '192.168.1.100', + 'username': 'deploy', + 'credentialId': 'cred-123', + 'description': 'Test server', + 'allowsDowntime': False + }) + + assert result['result'] == 'SUCCESS' + assert result['data']['name'] == 'test-server' + assert result['data']['targetType'] == 'ssh' + assert result['data']['address'] == '192.168.1.100' + assert result['data']['username'] == 'deploy' + assert result['data']['credentialId'] == 'cred-123' + assert 'id' in result['data'] + mock_repo.create_targets_table.assert_called_once() + mock_repo.insert_target.assert_called_once() + + def test_create_target_duplicate_name(self): + with patch('controller.target_create.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + mock_repo.insert_target.side_effect = Exception('duplicate key violation') + + result = create_target(request_data={ + 'name': 'existing-server', + 'targetType': 'ssh', + 'address': '10.0.0.1' + }) + + assert result['result'] == 'FAILURE' + assert 'already exists' in result['error'].lower() + + def test_create_target_minimal_fields(self): + with patch('controller.target_create.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + + result = create_target(request_data={ + 'name': 'minimal-target', + 'targetType': 'ssh', + 'address': '10.0.0.1' + }) + + assert result['result'] == 'SUCCESS' + assert result['data']['username'] is None + assert result['data']['credentialId'] is None + + +class TestTargetRetrieval: + + def test_get_target_missing_id(self): + result = get_target(request_data=None) + assert result['result'] == 'FAILURE' + assert 'Missing targetId' in result['error'] + + def test_get_target_not_found(self): + with patch('controller.target_get.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + mock_repo.fetch_target_by_id.return_value = None + + result = get_target(request_data={"targetId": str(uuid.uuid4())}) + assert result['result'] == 'FAILURE' + assert 'not found' in result['error'].lower() + + def test_get_target_success(self): + with patch('controller.target_get.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + + test_id = str(uuid.uuid4()) + mock_target = ( + test_id, 'test-server', 'ssh', '192.168.1.100', + 'deploy', 'cred-123', 'Test server', False, + None, None, None + ) + mock_repo.fetch_target_by_id.return_value = mock_target + + result = get_target(request_data={"targetId": test_id}) + + assert result['result'] == 'SUCCESS' + assert result['data']['id'] == test_id + assert result['data']['name'] == 'test-server' + assert result['data']['targetType'] == 'ssh' + assert result['data']['address'] == '192.168.1.100' + + +class TestTargetListing: + + def test_list_targets_empty(self): + with patch('controller.targets_get.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + mock_repo.fetch_targets_list.return_value = [] + + result = list_targets(request_data=None) + + assert result['result'] == 'SUCCESS' + assert result['data'] == [] + + def test_list_targets_multiple(self): + with patch('controller.targets_get.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + + id1 = str(uuid.uuid4()) + id2 = str(uuid.uuid4()) + mock_targets = [ + (id1, 'server-1', 'ssh', '10.0.0.1', 'root', None, 'SSH', False, None, None), + (id2, 'api-1', 'http', 'https://api.test.com', None, None, 'HTTP', False, None, None) + ] + mock_repo.fetch_targets_list.return_value = mock_targets + + result = list_targets(request_data=None) + + assert result['result'] == 'SUCCESS' + assert len(result['data']) == 2 + assert result['data'][0]['name'] == 'server-1' + assert result['data'][1]['name'] == 'api-1' + + +class TestTargetDeletion: + + def test_delete_target_missing_id(self): + result = delete_target(request_data=None) + assert result['result'] == 'FAILURE' + assert 'Missing targetId' in result['error'] + + def test_delete_target_not_found(self): + with patch('controller.target_delete.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + mock_repo.fetch_target_by_id.return_value = None + + result = delete_target(request_data={"targetId": str(uuid.uuid4())}) + assert result['result'] == 'FAILURE' + assert 'not found' in result['error'].lower() + + def test_delete_target_success(self): + with patch('controller.target_delete.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + + test_id = str(uuid.uuid4()) + mock_target = (test_id, 'test', 'ssh', '10.0.0.1', None, None, None, False, None, None, None) + mock_repo.fetch_target_by_id.return_value = mock_target + + result = delete_target(request_data={"targetId": test_id}) + + assert result['result'] == 'SUCCESS' + mock_repo.delete_target.assert_called_once_with(test_id) + + +class TestTargetErrorHandling: + + def test_get_target_database_error(self): + with patch('controller.target_get.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + mock_repo.fetch_target_by_id.side_effect = Exception('Database error') + + result = get_target(request_data={"targetId": str(uuid.uuid4())}) + assert result['result'] == 'FAILURE' + + def test_list_targets_database_error(self): + with patch('controller.targets_get.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + mock_repo.fetch_targets_list.side_effect = Exception('Database error') + + result = list_targets(request_data=None) + assert result['result'] == 'FAILURE' + + def test_delete_target_database_error(self): + with patch('controller.target_delete.MetadataDatabaseRepository') as mock_repo_class: + mock_repo = Mock() + mock_repo_class.return_value = mock_repo + mock_repo.fetch_target_by_id.side_effect = Exception('Database error') + + result = delete_target(request_data={"targetId": str(uuid.uuid4())}) + assert result['result'] == 'FAILURE'