mirror of
https://github.com/reconurge/flowsint.git
synced 2026-07-20 20:43:44 -05:00
feat(core): only support pydantic object insert and manipulation
This commit is contained in:
@@ -8,6 +8,7 @@ from .vault import VaultProtocol
|
||||
from .graph_service import GraphService, create_graph_service
|
||||
from ..utils import resolve_type
|
||||
|
||||
|
||||
class InvalidEnricherParams(Exception):
|
||||
pass
|
||||
|
||||
@@ -413,9 +414,7 @@ class Enricher(ABC):
|
||||
|
||||
async def execute(self, values: List[str]) -> List[Dict[str, Any]]:
|
||||
if self.name() != "enricher_orchestrator":
|
||||
Logger.info(
|
||||
self.sketch_id, {"message": f"Enricher {self.name()} started."}
|
||||
)
|
||||
Logger.info(self.sketch_id, {"message": f"Enricher {self.name()} started."})
|
||||
try:
|
||||
await self.async_init()
|
||||
preprocessed = self.preprocess(values)
|
||||
@@ -440,240 +439,50 @@ class Enricher(ABC):
|
||||
)
|
||||
return []
|
||||
|
||||
def create_node(
|
||||
self, node_type_or_obj, key_prop=None, key_value=None, **properties
|
||||
) -> None:
|
||||
def create_node(self, node_obj, **properties) -> None:
|
||||
"""
|
||||
Create a single Neo4j node.
|
||||
|
||||
This method now uses the GraphService for improved performance and
|
||||
better separation of concerns.
|
||||
|
||||
The following properties are automatically added to every node:
|
||||
- type: Lowercase version of node_type
|
||||
- sketch_id: Current sketch ID from enricher context
|
||||
- label: Automatically computed by FlowsintType, or defaults to key_value if not provided
|
||||
- created_at: ISO 8601 UTC timestamp (only on creation, not updates)
|
||||
|
||||
Best Practice - Use Pydantic object directly:
|
||||
The simplest way is to pass a Pydantic object directly. The node type,
|
||||
key property, and key value are automatically inferred:
|
||||
|
||||
Use Pydantic object directly:
|
||||
```python
|
||||
# Best: pass the Pydantic object directly
|
||||
self.create_node(ip)
|
||||
|
||||
# Also good if you need to override properties
|
||||
self.create_node(domain, type="subdomain")
|
||||
```
|
||||
|
||||
Args:
|
||||
node_type_or_obj: Either a Pydantic object (FlowsintType), or node label string (e.g., "domain", "ip")
|
||||
key_prop: Property name used as unique identifier (optional if passing Pydantic object)
|
||||
key_value: Value of the key property (optional if passing Pydantic object)
|
||||
**properties: Additional node properties or property overrides
|
||||
|
||||
Note:
|
||||
Uses MERGE semantics - if a node with the same (key_prop, sketch_id)
|
||||
exists, it will be updated. The created_at field is only set on creation.
|
||||
node_obj: Either a Pydantic object or node label string
|
||||
**properties: Additional node properties or overrides
|
||||
"""
|
||||
# Check if first argument is a Pydantic object
|
||||
if isinstance(node_type_or_obj, BaseModel):
|
||||
obj = node_type_or_obj
|
||||
|
||||
# Infer node_type from class name (e.g., Ip -> "ip", Domain -> "domain")
|
||||
node_type = obj.__class__.__name__.lower()
|
||||
|
||||
# Get the primary field and its value
|
||||
primary_field = self._get_primary_field(obj)
|
||||
key_prop = primary_field
|
||||
key_value = getattr(obj, primary_field)
|
||||
|
||||
# If key_value is itself a Pydantic model, extract its primary value
|
||||
if isinstance(key_value, BaseModel):
|
||||
key_value = self._extract_primary_value(key_value)
|
||||
|
||||
# Merge object properties with any overrides, but skip nested Pydantic objects
|
||||
# Use model_dump(mode="json") to properly serialize Pydantic types (e.g., HttpUrl)
|
||||
obj_dict = obj.model_dump(mode="json") if hasattr(obj, "model_dump") else obj.dict()
|
||||
obj_properties = {}
|
||||
for k, v in obj_dict.items():
|
||||
# Skip nested Pydantic objects (represented as dicts after model_dump)
|
||||
if not isinstance(v, dict):
|
||||
obj_properties[k] = v
|
||||
obj_properties.update(properties)
|
||||
properties = obj_properties
|
||||
else:
|
||||
# Legacy signature: node_type_or_obj is the node_type string
|
||||
node_type = node_type_or_obj
|
||||
|
||||
self._graph_service.create_node(
|
||||
node_type=node_type, key_prop=key_prop, key_value=key_value, **properties
|
||||
)
|
||||
|
||||
def _serialize_properties(self, properties: dict) -> dict:
|
||||
"""
|
||||
Convert properties to Neo4j-compatible values.
|
||||
|
||||
DEPRECATED: This method is kept for backward compatibility.
|
||||
New code should use GraphSerializer directly.
|
||||
|
||||
Args:
|
||||
properties: Dictionary of properties to serialize
|
||||
|
||||
Returns:
|
||||
Dictionary of serialized properties
|
||||
"""
|
||||
from .graph_serializer import GraphSerializer
|
||||
|
||||
return GraphSerializer.serialize_properties(properties)
|
||||
self._graph_service.create_node(node_obj=node_obj, **properties)
|
||||
|
||||
def create_relationship(
|
||||
self,
|
||||
from_type_or_obj,
|
||||
from_key_or_to_obj,
|
||||
from_value_or_rel_type=None,
|
||||
to_type=None,
|
||||
to_key=None,
|
||||
to_value=None,
|
||||
rel_type=None,
|
||||
from_obj,
|
||||
to_obj,
|
||||
rel_label="IS_RELATED_TO",
|
||||
) -> None:
|
||||
"""
|
||||
Create a relationship between two nodes.
|
||||
|
||||
This method now uses the GraphService for improved performance and
|
||||
better separation of concerns.
|
||||
|
||||
Best Practice - Use Pydantic objects directly:
|
||||
The simplest way is to pass two Pydantic objects and the relationship type:
|
||||
|
||||
```python
|
||||
# Best: pass Pydantic objects directly
|
||||
self.create_relationship(individual, domain, "HAS_DOMAIN")
|
||||
self.create_relationship(email, breach, "FOUND_IN_BREACH")
|
||||
```
|
||||
|
||||
Legacy Usage:
|
||||
You can still use the explicit signature for backward compatibility:
|
||||
|
||||
```python
|
||||
# Legacy: explicit signature
|
||||
self.create_relationship(
|
||||
"individual", "full_name", individual.full_name,
|
||||
"domain", "domain", domain_name,
|
||||
"HAS_DOMAIN"
|
||||
)
|
||||
```
|
||||
|
||||
Args:
|
||||
from_type_or_obj: Either a Pydantic object (source node) or source node label string
|
||||
from_key_or_to_obj: Either a Pydantic object (target node) or source node key property
|
||||
from_value_or_rel_type: Either relationship type string (if using objects) or source node key value
|
||||
to_type: Target node label (only for legacy signature)
|
||||
to_key: Target node key property (only for legacy signature)
|
||||
to_value: Target node key value (only for legacy signature)
|
||||
rel_type: Relationship type (only for legacy signature)
|
||||
from_obj: Either a Pydantic object (source) or source node label
|
||||
to_obj: Either a Pydantic object (target) or source node key property
|
||||
rel_label: Either relationship type (Pydantic) or source node key value
|
||||
"""
|
||||
# Check if using new signature (Pydantic objects)
|
||||
if isinstance(from_type_or_obj, BaseModel) and isinstance(from_key_or_to_obj, BaseModel):
|
||||
from_obj = from_type_or_obj
|
||||
to_obj = from_key_or_to_obj
|
||||
relationship_type = from_value_or_rel_type
|
||||
|
||||
# Extract from_node info
|
||||
from_node_type = from_obj.__class__.__name__.lower()
|
||||
from_primary_field = self._get_primary_field(from_obj)
|
||||
|
||||
# Use model_dump to properly serialize Pydantic types (e.g., HttpUrl)
|
||||
from_obj_dict = from_obj.model_dump(mode="json") if hasattr(from_obj, "model_dump") else from_obj.dict()
|
||||
from_key_value = from_obj_dict.get(from_primary_field)
|
||||
|
||||
# If key_value is still a dict (nested Pydantic model), extract its primary value
|
||||
if isinstance(from_key_value, dict):
|
||||
# Get the raw nested object to extract its primary value
|
||||
nested_obj = getattr(from_obj, from_primary_field)
|
||||
if isinstance(nested_obj, BaseModel):
|
||||
from_key_value = self._extract_primary_value(nested_obj)
|
||||
|
||||
# Extract to_node info
|
||||
to_node_type = to_obj.__class__.__name__.lower()
|
||||
to_primary_field = self._get_primary_field(to_obj)
|
||||
|
||||
# Use model_dump to properly serialize Pydantic types (e.g., HttpUrl)
|
||||
to_obj_dict = to_obj.model_dump(mode="json") if hasattr(to_obj, "model_dump") else to_obj.dict()
|
||||
to_key_value = to_obj_dict.get(to_primary_field)
|
||||
|
||||
# If key_value is still a dict (nested Pydantic model), extract its primary value
|
||||
if isinstance(to_key_value, dict):
|
||||
# Get the raw nested object to extract its primary value
|
||||
nested_obj = getattr(to_obj, to_primary_field)
|
||||
if isinstance(nested_obj, BaseModel):
|
||||
to_key_value = self._extract_primary_value(nested_obj)
|
||||
|
||||
self._graph_service.create_relationship(
|
||||
from_type=from_node_type,
|
||||
from_key=from_primary_field,
|
||||
from_value=from_key_value,
|
||||
to_type=to_node_type,
|
||||
to_key=to_primary_field,
|
||||
to_value=to_key_value,
|
||||
rel_type=relationship_type,
|
||||
)
|
||||
else:
|
||||
# Legacy signature
|
||||
self._graph_service.create_relationship(
|
||||
from_type=from_type_or_obj,
|
||||
from_key=from_key_or_to_obj,
|
||||
from_value=from_value_or_rel_type,
|
||||
to_type=to_type,
|
||||
to_key=to_key,
|
||||
to_value=to_value,
|
||||
rel_type=rel_type,
|
||||
)
|
||||
|
||||
def _get_primary_field(self, obj: BaseModel) -> str:
|
||||
"""Helper method to get the primary field of a Pydantic object."""
|
||||
# Access model_fields from the class, not the instance
|
||||
model_fields = obj.__class__.model_fields
|
||||
|
||||
# Find the primary field (marked with json_schema_extra={"primary": True})
|
||||
primary_field = None
|
||||
for field_name, field_info in model_fields.items():
|
||||
if field_info.json_schema_extra and field_info.json_schema_extra.get("primary"):
|
||||
primary_field = field_name
|
||||
break
|
||||
|
||||
# Fallback: use first required field or first field
|
||||
if primary_field is None:
|
||||
for field_name, field_info in model_fields.items():
|
||||
if field_info.is_required():
|
||||
primary_field = field_name
|
||||
break
|
||||
if primary_field is None:
|
||||
primary_field = next(iter(model_fields.keys()))
|
||||
|
||||
return primary_field
|
||||
|
||||
def _extract_primary_value(self, obj: BaseModel) -> Any:
|
||||
"""
|
||||
Extract the primitive value from a Pydantic object recursively.
|
||||
If the primary field is itself a Pydantic object, recursively extract its primary value.
|
||||
Uses model_dump to properly serialize Pydantic types like HttpUrl.
|
||||
"""
|
||||
primary_field = self._get_primary_field(obj)
|
||||
|
||||
# Use model_dump to properly serialize Pydantic types (e.g., HttpUrl)
|
||||
obj_dict = obj.model_dump(mode="json") if hasattr(obj, "model_dump") else obj.dict()
|
||||
value = obj_dict.get(primary_field)
|
||||
|
||||
# If the value is still a dict (nested Pydantic model), recursively extract
|
||||
if isinstance(value, dict):
|
||||
# Get the raw nested object to recursively extract
|
||||
nested_obj = getattr(obj, primary_field)
|
||||
if isinstance(nested_obj, BaseModel):
|
||||
return self._extract_primary_value(nested_obj)
|
||||
|
||||
return value
|
||||
self._graph_service.create_relationship(
|
||||
from_obj=from_obj, to_obj=to_obj, rel_label=rel_label
|
||||
)
|
||||
|
||||
def log_graph_message(self, message: str) -> None:
|
||||
"""
|
||||
|
||||
@@ -91,9 +91,17 @@ class Neo4jConnection:
|
||||
List of result records as dictionaries
|
||||
"""
|
||||
with self._driver.session() as session:
|
||||
result = session.run(query, parameters or {})
|
||||
cleaned_params = self._clean_parameters(parameters)
|
||||
result = session.run(query, cleaned_params)
|
||||
return result.data()
|
||||
|
||||
@staticmethod
|
||||
def _clean_parameters(parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove None keys from parameters dict to avoid Neo4j errors."""
|
||||
if not parameters:
|
||||
return {}
|
||||
return {k: v for k, v in parameters.items() if k is not None}
|
||||
|
||||
def execute_write(self, query: str, parameters: Dict[str, Any] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute a write query within a write transaction.
|
||||
@@ -106,7 +114,8 @@ class Neo4jConnection:
|
||||
List of result records as dictionaries
|
||||
"""
|
||||
def _execute(tx):
|
||||
result = tx.run(query, parameters or {})
|
||||
cleaned_params = self._clean_parameters(parameters)
|
||||
result = tx.run(query, cleaned_params)
|
||||
return result.data()
|
||||
|
||||
with self._driver.session() as session:
|
||||
@@ -121,7 +130,8 @@ class Neo4jConnection:
|
||||
"""
|
||||
def _execute_batch(tx):
|
||||
for query, params in queries:
|
||||
tx.run(query, params or {})
|
||||
cleaned_params = self._clean_parameters(params)
|
||||
tx.run(query, cleaned_params)
|
||||
|
||||
with self._driver.session() as session:
|
||||
session.execute_write(_execute_batch)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ class GraphSerializer:
|
||||
- Nested objects (flattened with prefixed keys)
|
||||
- Lists (converted to primitive types)
|
||||
- Dictionaries (flattened with prefixed keys)
|
||||
- None values (converted to empty strings)
|
||||
- None values (skipped)
|
||||
|
||||
Args:
|
||||
properties: Dictionary of properties to serialize
|
||||
@@ -37,6 +37,8 @@ class GraphSerializer:
|
||||
serialized = {}
|
||||
|
||||
for key, value in properties.items():
|
||||
if key is None:
|
||||
continue
|
||||
if value is None:
|
||||
serialized[key] = ""
|
||||
elif GraphSerializer._is_pydantic_model(value):
|
||||
@@ -84,21 +86,24 @@ class GraphSerializer:
|
||||
|
||||
# Try Pydantic v2 first, then v1
|
||||
if hasattr(model, "model_dump"):
|
||||
data = model.model_dump()
|
||||
data = model.model_dump(mode="json")
|
||||
elif hasattr(model, "dict"):
|
||||
data = model.dict()
|
||||
else:
|
||||
# Fallback to __dict__
|
||||
data = model.__dict__
|
||||
data = {k: v for k, v in model.__dict__.items() if k is not None}
|
||||
|
||||
for nested_key, nested_value in data.items():
|
||||
if nested_value is not None:
|
||||
new_key = f"{prefix}_{nested_key}"
|
||||
if isinstance(nested_value, (str, int, float, bool)):
|
||||
flattened[new_key] = nested_value
|
||||
else:
|
||||
# Recursively handle nested complex types
|
||||
flattened[new_key] = str(nested_value)
|
||||
if nested_key is None:
|
||||
continue
|
||||
new_key = f"{prefix}_{nested_key}"
|
||||
if nested_value is None:
|
||||
flattened[new_key] = ""
|
||||
elif isinstance(nested_value, (str, int, float, bool)):
|
||||
flattened[new_key] = nested_value
|
||||
else:
|
||||
# Recursively handle nested complex types
|
||||
flattened[new_key] = str(nested_value)
|
||||
|
||||
return flattened
|
||||
|
||||
@@ -117,12 +122,15 @@ class GraphSerializer:
|
||||
flattened = {}
|
||||
|
||||
for dict_key, dict_value in data.items():
|
||||
if dict_value is not None:
|
||||
new_key = f"{prefix}_{dict_key}"
|
||||
if isinstance(dict_value, (str, int, float, bool)):
|
||||
flattened[new_key] = dict_value
|
||||
else:
|
||||
flattened[new_key] = str(dict_value)
|
||||
if dict_key is None:
|
||||
continue
|
||||
new_key = f"{prefix}_{dict_key}"
|
||||
if dict_value is None:
|
||||
flattened[new_key] = ""
|
||||
elif isinstance(dict_value, (str, int, float, bool)):
|
||||
flattened[new_key] = dict_value
|
||||
else:
|
||||
flattened[new_key] = str(dict_value)
|
||||
|
||||
return flattened
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ This module provides a service layer for graph operations,
|
||||
integrating repository and logging functionality.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from typing import Dict, Any, Optional, Protocol, Union
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
from .graph_repository import GraphRepository
|
||||
from .graph_db import Neo4jConnection
|
||||
|
||||
@@ -33,7 +34,7 @@ class GraphService:
|
||||
sketch_id: str,
|
||||
neo4j_connection: Optional[Neo4jConnection] = None,
|
||||
logger: Optional[LoggerProtocol] = None,
|
||||
enable_batching: bool = True
|
||||
enable_batching: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize the graph service.
|
||||
@@ -59,94 +60,66 @@ class GraphService:
|
||||
"""Get the underlying repository."""
|
||||
return self._repository
|
||||
|
||||
def create_node(
|
||||
self,
|
||||
node_type: str,
|
||||
key_prop: str,
|
||||
key_value: str,
|
||||
**properties: Any
|
||||
) -> None:
|
||||
def create_node(self, node_obj: BaseModel, **properties: Any) -> None:
|
||||
"""
|
||||
Create or update a node in the graph.
|
||||
|
||||
Automatically adds the following properties:
|
||||
- type: Lowercase version of node_type
|
||||
- sketch_id: Current sketch ID
|
||||
- label: Defaults to key_value if not provided
|
||||
- created_at: ISO 8601 UTC timestamp (only on creation via ON CREATE SET)
|
||||
Supports one signatures:
|
||||
- Pydantic object: create_node(obj, **overrides)
|
||||
|
||||
Args:
|
||||
node_type: Node label (e.g., "domain", "ip")
|
||||
key_prop: Property name used as unique identifier
|
||||
key_value: Value of the key property
|
||||
**properties: Additional node properties
|
||||
from_obj: a Pydantic object
|
||||
**properties: Additional node properties or overrides
|
||||
"""
|
||||
if self._enable_batching:
|
||||
self._repository.add_to_batch(
|
||||
"node",
|
||||
node_type=node_type,
|
||||
key_prop=key_prop,
|
||||
key_value=key_value,
|
||||
node_obj=node_obj,
|
||||
sketch_id=self._sketch_id,
|
||||
**properties
|
||||
**properties,
|
||||
)
|
||||
else:
|
||||
self._repository.create_node(
|
||||
node_type=node_type,
|
||||
key_prop=key_prop,
|
||||
key_value=key_value,
|
||||
node_obj=node_obj,
|
||||
sketch_id=self._sketch_id,
|
||||
**properties
|
||||
**properties,
|
||||
)
|
||||
|
||||
def create_relationship(
|
||||
self,
|
||||
from_type: str,
|
||||
from_key: str,
|
||||
from_value: str,
|
||||
to_type: str,
|
||||
to_key: str,
|
||||
to_value: str,
|
||||
rel_type: str,
|
||||
**properties: Any
|
||||
from_obj: BaseModel,
|
||||
to_obj: BaseModel,
|
||||
rel_label: Optional[str] = None,
|
||||
**properties: Any,
|
||||
) -> None:
|
||||
"""
|
||||
Create a relationship between two nodes.
|
||||
|
||||
Supports 1 signature:
|
||||
- Pydantic objects: create_relationship(obj1, obj2, "REL_TYPE")
|
||||
|
||||
Args:
|
||||
from_type: Source node label
|
||||
from_key: Source node key property
|
||||
from_value: Source node key value
|
||||
to_type: Target node label
|
||||
to_key: Target node key property
|
||||
to_value: Target node key value
|
||||
rel_type: Relationship type
|
||||
from_obj: Either a Pydantic object (source)
|
||||
to_obj: Either a Pydantic object (target)
|
||||
rel_label: Relationship label (ex: "IS_CONNECTED_TO")
|
||||
**properties: Additional relationship properties
|
||||
"""
|
||||
if self._enable_batching:
|
||||
self._repository.add_to_batch(
|
||||
"relationship",
|
||||
from_type=from_type,
|
||||
from_key=from_key,
|
||||
from_value=from_value,
|
||||
to_type=to_type,
|
||||
to_key=to_key,
|
||||
to_value=to_value,
|
||||
rel_type=rel_type,
|
||||
from_obj=from_obj,
|
||||
to_obj=to_obj,
|
||||
rel_label=rel_label,
|
||||
sketch_id=self._sketch_id,
|
||||
**properties
|
||||
**properties,
|
||||
)
|
||||
else:
|
||||
self._repository.create_relationship(
|
||||
from_type=from_type,
|
||||
from_key=from_key,
|
||||
from_value=from_value,
|
||||
to_type=to_type,
|
||||
to_key=to_key,
|
||||
to_value=to_value,
|
||||
rel_type=rel_type,
|
||||
from_obj=from_obj,
|
||||
to_obj=to_obj,
|
||||
rel_label=rel_label,
|
||||
sketch_id=self._sketch_id,
|
||||
**properties
|
||||
**properties,
|
||||
)
|
||||
|
||||
def log_graph_message(self, message: str) -> None:
|
||||
@@ -199,7 +172,7 @@ class GraphService:
|
||||
def create_graph_service(
|
||||
sketch_id: str,
|
||||
neo4j_connection: Optional[Neo4jConnection] = None,
|
||||
enable_batching: bool = True
|
||||
enable_batching: bool = True,
|
||||
) -> GraphService:
|
||||
"""
|
||||
Factory function to create a GraphService instance.
|
||||
@@ -219,5 +192,5 @@ def create_graph_service(
|
||||
sketch_id=sketch_id,
|
||||
neo4j_connection=neo4j_connection,
|
||||
logger=Logger,
|
||||
enable_batching=enable_batching
|
||||
enable_batching=enable_batching,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Test simplified API for create_node and create_relationship."""
|
||||
|
||||
import pytest
|
||||
from flowsint_core.core.enricher_base import Enricher
|
||||
from flowsint_types.domain import Domain
|
||||
from flowsint_types.email import Email
|
||||
from flowsint_types.individual import Individual
|
||||
from typing import List
|
||||
|
||||
|
||||
class MockEnricher(Enricher):
|
||||
"""Simple enricher for testing."""
|
||||
|
||||
InputType = Domain
|
||||
OutputType = Domain
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "test_enricher"
|
||||
|
||||
@classmethod
|
||||
def category(cls) -> str:
|
||||
return "Test"
|
||||
|
||||
@classmethod
|
||||
def key(cls) -> str:
|
||||
return "domain"
|
||||
|
||||
async def scan(self, data: List[InputType]) -> List[OutputType]:
|
||||
return data
|
||||
|
||||
|
||||
def test_create_relationship_with_pydantic_objects():
|
||||
"""Test that create_relationship works with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Create objects
|
||||
individual = Individual(first_name="John", last_name="Doe", full_name="John Doe")
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# This should not raise an error
|
||||
enricher.create_relationship(individual, domain, "HAS_DOMAIN")
|
||||
|
||||
|
||||
def test_create_node_with_property_override():
|
||||
"""Test that property overrides work with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Should be able to override properties
|
||||
enricher.create_node(domain, type="subdomain")
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Test GraphRepository batch operations."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from flowsint_core.core.graph_repository import GraphRepository
|
||||
from flowsint_types.domain import Domain
|
||||
from flowsint_types.ip import Ip
|
||||
from flowsint_types.email import Email
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_connection():
|
||||
"""Create a mock Neo4j connection."""
|
||||
conn = Mock()
|
||||
conn.execute_batch = Mock(return_value=[])
|
||||
conn.query = Mock(return_value=[])
|
||||
return conn
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_repo(mock_connection):
|
||||
"""Create a GraphRepository with mocked connection."""
|
||||
return GraphRepository(neo4j_connection=mock_connection)
|
||||
|
||||
|
||||
def test_batch_create_nodes_empty_list(graph_repo):
|
||||
"""Test batch_create_nodes with empty list."""
|
||||
result = graph_repo.batch_create_nodes(nodes=[], sketch_id="test-sketch")
|
||||
|
||||
assert result["nodes_created"] == 0
|
||||
assert result["node_ids"] == []
|
||||
assert result["errors"] == []
|
||||
|
||||
|
||||
def test_batch_create_nodes_single_node(graph_repo, mock_connection):
|
||||
"""Test batch_create_nodes with a single node."""
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Mock the batch execution to return a node ID
|
||||
mock_connection.execute_batch.return_value = [
|
||||
[{"id": "element-id-123"}]
|
||||
]
|
||||
|
||||
result = graph_repo.batch_create_nodes(
|
||||
nodes=[domain],
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result["nodes_created"] == 1
|
||||
assert len(result["node_ids"]) == 1
|
||||
assert result["node_ids"][0] == "element-id-123"
|
||||
assert result["errors"] == []
|
||||
|
||||
# Verify execute_batch was called once
|
||||
assert mock_connection.execute_batch.call_count == 1
|
||||
|
||||
|
||||
def test_batch_create_nodes_multiple_nodes(graph_repo, mock_connection):
|
||||
"""Test batch_create_nodes with multiple nodes of different types."""
|
||||
domain = Domain(domain="example.com")
|
||||
ip = Ip(address="192.168.1.1")
|
||||
email = Email(email="test@example.com")
|
||||
|
||||
# Mock the batch execution to return multiple node IDs
|
||||
mock_connection.execute_batch.return_value = [
|
||||
[{"id": "element-id-1"}],
|
||||
[{"id": "element-id-2"}],
|
||||
[{"id": "element-id-3"}]
|
||||
]
|
||||
|
||||
result = graph_repo.batch_create_nodes(
|
||||
nodes=[domain, ip, email],
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result["nodes_created"] == 3
|
||||
assert len(result["node_ids"]) == 3
|
||||
assert result["errors"] == []
|
||||
|
||||
# Verify execute_batch was called once with 3 operations
|
||||
assert mock_connection.execute_batch.call_count == 1
|
||||
batch_operations = mock_connection.execute_batch.call_args[0][0]
|
||||
assert len(batch_operations) == 3
|
||||
|
||||
|
||||
def test_batch_create_nodes_with_validation_errors(graph_repo, mock_connection):
|
||||
"""Test batch_create_nodes when some nodes have validation errors."""
|
||||
valid_domain = Domain(domain="example.com")
|
||||
|
||||
# Mock execute_batch to succeed for valid nodes
|
||||
mock_connection.execute_batch.return_value = [
|
||||
[{"id": "element-id-1"}]
|
||||
]
|
||||
|
||||
result = graph_repo.batch_create_nodes(
|
||||
nodes=[valid_domain],
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result["nodes_created"] == 1
|
||||
assert len(result["errors"]) == 0
|
||||
|
||||
|
||||
def test_batch_create_nodes_batch_execution_failure(graph_repo, mock_connection):
|
||||
"""Test batch_create_nodes when the batch execution fails."""
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Mock execute_batch to raise an exception
|
||||
mock_connection.execute_batch.side_effect = Exception("Database connection error")
|
||||
|
||||
result = graph_repo.batch_create_nodes(
|
||||
nodes=[domain],
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result["nodes_created"] == 0
|
||||
assert result["node_ids"] == []
|
||||
assert len(result["errors"]) > 0
|
||||
assert "Database connection error" in result["errors"][0]
|
||||
|
||||
|
||||
def test_batch_create_nodes_no_connection():
|
||||
"""Test batch_create_nodes when there's no database connection."""
|
||||
# Create a repository with no connection by manually setting it to None
|
||||
repo = GraphRepository(neo4j_connection=Mock())
|
||||
repo._connection = None # Force no connection
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
result = repo.batch_create_nodes(
|
||||
nodes=[domain],
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result["nodes_created"] == 0
|
||||
assert result["node_ids"] == []
|
||||
assert len(result["errors"]) == 1
|
||||
assert "No database connection" in result["errors"][0]
|
||||
|
||||
|
||||
def test_batch_create_nodes_with_label_fallback(graph_repo, mock_connection):
|
||||
"""Test batch_create_nodes with nodes that need label fallback."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class CustomNode(BaseModel):
|
||||
"""Node type without a clear primary field."""
|
||||
name: str = ""
|
||||
label: str = "Custom Node"
|
||||
|
||||
node = CustomNode(name="", label="Fallback Label")
|
||||
|
||||
# Mock the batch execution
|
||||
mock_connection.execute_batch.return_value = [
|
||||
[{"id": "element-id-fallback"}]
|
||||
]
|
||||
|
||||
result = graph_repo.batch_create_nodes(
|
||||
nodes=[node],
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
# Should succeed with fallback to label
|
||||
assert result["nodes_created"] == 1
|
||||
assert len(result["node_ids"]) == 1
|
||||
|
||||
|
||||
def test_batch_create_nodes_large_batch(graph_repo, mock_connection):
|
||||
"""Test batch_create_nodes with a large number of nodes."""
|
||||
# Create 1000 domains
|
||||
domains = [Domain(domain=f"example{i}.com") for i in range(1000)]
|
||||
|
||||
# Mock the batch execution to return 1000 node IDs
|
||||
mock_connection.execute_batch.return_value = [
|
||||
[{"id": f"element-id-{i}"}] for i in range(1000)
|
||||
]
|
||||
|
||||
result = graph_repo.batch_create_nodes(
|
||||
nodes=domains,
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result["nodes_created"] == 1000
|
||||
assert len(result["node_ids"]) == 1000
|
||||
assert result["errors"] == []
|
||||
|
||||
# Verify execute_batch was called once (single transaction)
|
||||
assert mock_connection.execute_batch.call_count == 1
|
||||
batch_operations = mock_connection.execute_batch.call_args[0][0]
|
||||
assert len(batch_operations) == 1000
|
||||
|
||||
|
||||
# Tests for update_node
|
||||
|
||||
|
||||
def test_update_node_success(graph_repo, mock_connection):
|
||||
"""Test update_node with a valid Pydantic object."""
|
||||
domain = Domain(domain="updated-example.com", label="Updated Domain")
|
||||
|
||||
# Mock the query to return the element ID
|
||||
mock_connection.query.return_value = [{"id": "element-id-123"}]
|
||||
|
||||
result = graph_repo.update_node(
|
||||
element_id="element-id-123",
|
||||
node_obj=domain,
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result == "element-id-123"
|
||||
assert mock_connection.query.call_count == 1
|
||||
|
||||
|
||||
def test_update_node_not_found(graph_repo, mock_connection):
|
||||
"""Test update_node when node doesn't exist."""
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Mock the query to return empty result
|
||||
mock_connection.query.return_value = []
|
||||
|
||||
result = graph_repo.update_node(
|
||||
element_id="non-existent-id",
|
||||
node_obj=domain,
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_update_node_no_connection():
|
||||
"""Test update_node when there's no database connection."""
|
||||
repo = GraphRepository(neo4j_connection=Mock())
|
||||
repo._connection = None
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
result = repo.update_node(
|
||||
element_id="element-id-123",
|
||||
node_obj=domain,
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_update_node_different_types(graph_repo, mock_connection):
|
||||
"""Test update_node with different Pydantic types."""
|
||||
ip = Ip(address="10.0.0.1", label="Updated IP")
|
||||
|
||||
# Mock the query
|
||||
mock_connection.query.return_value = [{"id": "element-id-456"}]
|
||||
|
||||
result = graph_repo.update_node(
|
||||
element_id="element-id-456",
|
||||
node_obj=ip,
|
||||
sketch_id="test-sketch"
|
||||
)
|
||||
|
||||
assert result == "element-id-456"
|
||||
|
||||
# Verify the query contains the correct type
|
||||
query_call = mock_connection.query.call_args
|
||||
params = query_call[0][1]
|
||||
assert params["type"] == "ip"
|
||||
+5
-3
@@ -35,9 +35,11 @@ def mock_db_session():
|
||||
@pytest.fixture
|
||||
def mock_get_db(mock_db_session):
|
||||
"""Mock the get_db generator."""
|
||||
with patch('flowsint_core.core.logger.get_db') as mock:
|
||||
mock.return_value = iter([mock_db_session])
|
||||
yield mock
|
||||
def mock_generator():
|
||||
yield mock_db_session
|
||||
|
||||
with patch('flowsint_core.core.logger.get_db', mock_generator):
|
||||
yield mock_generator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1,110 +0,0 @@
|
||||
"""Test simplified API for create_node and create_relationship."""
|
||||
|
||||
import pytest
|
||||
from flowsint_core.core.enricher_base import Enricher
|
||||
from flowsint_types.domain import Domain
|
||||
from flowsint_types.email import Email
|
||||
from flowsint_types.individual import Individual
|
||||
from typing import List
|
||||
|
||||
|
||||
class MockEnricher(Enricher):
|
||||
"""Simple enricher for testing."""
|
||||
|
||||
InputType = Domain
|
||||
OutputType = Domain
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "test_enricher"
|
||||
|
||||
@classmethod
|
||||
def category(cls) -> str:
|
||||
return "Test"
|
||||
|
||||
@classmethod
|
||||
def key(cls) -> str:
|
||||
return "domain"
|
||||
|
||||
async def scan(self, data: List[InputType]) -> List[OutputType]:
|
||||
return data
|
||||
|
||||
|
||||
def test_create_node_with_pydantic_object():
|
||||
"""Test that create_node works with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Create a domain object
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# This should not raise an error
|
||||
enricher.create_node(domain)
|
||||
|
||||
# Verify the helper method works
|
||||
primary_field = enricher._get_primary_field(domain)
|
||||
assert primary_field == "domain"
|
||||
|
||||
|
||||
def test_create_relationship_with_pydantic_objects():
|
||||
"""Test that create_relationship works with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Create objects
|
||||
individual = Individual(first_name="John", last_name="Doe", full_name="John Doe")
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# This should not raise an error
|
||||
enricher.create_relationship(individual, domain, "HAS_DOMAIN")
|
||||
|
||||
|
||||
def test_create_node_legacy_signature():
|
||||
"""Test that legacy create_node signature still works."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Legacy signature should still work
|
||||
enricher.create_node("domain", "domain", "example.com", **domain.__dict__)
|
||||
|
||||
|
||||
def test_create_relationship_legacy_signature():
|
||||
"""Test that legacy create_relationship signature still works."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Legacy signature should still work
|
||||
enricher.create_relationship(
|
||||
"individual",
|
||||
"full_name",
|
||||
"John Doe",
|
||||
"domain",
|
||||
"domain",
|
||||
"example.com",
|
||||
"HAS_DOMAIN",
|
||||
)
|
||||
|
||||
|
||||
def test_get_primary_field():
|
||||
"""Test the _get_primary_field helper method."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Test with Domain (has primary field marked)
|
||||
domain = Domain(domain="example.com")
|
||||
assert enricher._get_primary_field(domain) == "domain"
|
||||
|
||||
# Test with Email (has primary field marked)
|
||||
email = Email(email="test@example.com")
|
||||
assert enricher._get_primary_field(email) == "email"
|
||||
|
||||
# Test with Individual (no primary field marked, falls back to first required field)
|
||||
individual = Individual(first_name="John", last_name="Doe", full_name="John Doe")
|
||||
assert enricher._get_primary_field(individual) == "full_name"
|
||||
|
||||
|
||||
def test_create_node_with_property_override():
|
||||
"""Test that property overrides work with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Should be able to override properties
|
||||
enricher.create_node(domain, type="subdomain")
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Test simplified API for create_node and create_relationship."""
|
||||
import pytest
|
||||
from flowsint_core.core.enricher_base import Enricher
|
||||
from flowsint_types.domain import Domain
|
||||
from flowsint_types.email import Email
|
||||
from flowsint_types.individual import Individual
|
||||
from typing import List
|
||||
|
||||
|
||||
class MockEnricher(Enricher):
|
||||
"""Simple enricher for testing."""
|
||||
|
||||
InputType = Domain
|
||||
OutputType = Domain
|
||||
|
||||
@classmethod
|
||||
def name(cls) -> str:
|
||||
return "test_enricher"
|
||||
|
||||
@classmethod
|
||||
def category(cls) -> str:
|
||||
return "Test"
|
||||
|
||||
@classmethod
|
||||
def key(cls) -> str:
|
||||
return "domain"
|
||||
|
||||
async def scan(self, data: List[InputType]) -> List[OutputType]:
|
||||
return data
|
||||
|
||||
|
||||
def test_create_node_with_pydantic_object():
|
||||
"""Test that create_node works with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Create a domain object
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# This should not raise an error
|
||||
enricher.create_node(domain)
|
||||
|
||||
# Verify the helper method works
|
||||
primary_field = enricher._get_primary_field(domain)
|
||||
assert primary_field == "domain"
|
||||
|
||||
|
||||
def test_create_relationship_with_pydantic_objects():
|
||||
"""Test that create_relationship works with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Create objects
|
||||
individual = Individual(
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
full_name="John Doe"
|
||||
)
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# This should not raise an error
|
||||
enricher.create_relationship(individual, domain, "HAS_DOMAIN")
|
||||
|
||||
|
||||
def test_create_node_legacy_signature():
|
||||
"""Test that legacy create_node signature still works."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Legacy signature should still work
|
||||
enricher.create_node("domain", "domain", "example.com", **domain.__dict__)
|
||||
|
||||
|
||||
def test_create_relationship_legacy_signature():
|
||||
"""Test that legacy create_relationship signature still works."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Legacy signature should still work
|
||||
enricher.create_relationship(
|
||||
"individual",
|
||||
"full_name",
|
||||
"John Doe",
|
||||
"domain",
|
||||
"domain",
|
||||
"example.com",
|
||||
"HAS_DOMAIN"
|
||||
)
|
||||
|
||||
|
||||
def test_get_primary_field():
|
||||
"""Test the _get_primary_field helper method."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
# Test with Domain (has primary field marked)
|
||||
domain = Domain(domain="example.com")
|
||||
assert enricher._get_primary_field(domain) == "domain"
|
||||
|
||||
# Test with Email (has primary field marked)
|
||||
email = Email(email="test@example.com")
|
||||
assert enricher._get_primary_field(email) == "email"
|
||||
|
||||
# Test with Individual (no primary field marked, falls back to first required field)
|
||||
individual = Individual(
|
||||
first_name="John",
|
||||
last_name="Doe",
|
||||
full_name="John Doe"
|
||||
)
|
||||
assert enricher._get_primary_field(individual) == "full_name"
|
||||
|
||||
|
||||
def test_create_node_with_property_override():
|
||||
"""Test that property overrides work with Pydantic objects."""
|
||||
enricher = MockEnricher(sketch_id="test", scan_id="test")
|
||||
|
||||
domain = Domain(domain="example.com")
|
||||
|
||||
# Should be able to override properties
|
||||
enricher.create_node(domain, type="subdomain")
|
||||
Reference in New Issue
Block a user