From 277e0ab276049751f64d93ffa96e7c0419cfb5d6 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Thu, 14 Aug 2025 14:26:31 -0600 Subject: [PATCH 01/22] add create_question with tests and examples --- docs/create_question_usage.md | 195 ++++++++ .../07_account_parameters_list_example.py | 79 +++ examples/08_questions_management.py | 366 ++++++++++++++ examples/README.md | 30 ++ jupiterone/client.py | 96 ++++ jupiterone/constants.py | 35 ++ tests/test_create_question.py | 467 ++++++++++++++++++ 7 files changed, 1268 insertions(+) create mode 100644 docs/create_question_usage.md create mode 100644 examples/07_account_parameters_list_example.py create mode 100644 examples/08_questions_management.py create mode 100644 tests/test_create_question.py diff --git a/docs/create_question_usage.md b/docs/create_question_usage.md new file mode 100644 index 0000000..a4396ab --- /dev/null +++ b/docs/create_question_usage.md @@ -0,0 +1,195 @@ +# Create Question Method Documentation + +## Overview + +The `create_question` method allows you to programmatically create questions in your JupiterOne account. Questions are saved queries that can be run on-demand or on a schedule to monitor your infrastructure and security posture. + +## Method Signature + +```python +def create_question( + self, + title: str, + queries: List[Dict], + resource_group_id: str = None, + **kwargs +) +``` + +## Parameters + +### Required Parameters + +- **`title`** (str): The title of the question. This is required and cannot be empty. + +- **`queries`** (List[Dict]): A list of query objects. At least one query is required. Each query object can contain: + - **`query`** (str, required): The J1QL query string + - **`name`** (str, optional): Name for the query. Defaults to "Query{index}" + - **`version`** (str, optional): Query version (e.g., "v1") + - **`resultsAre`** (str, optional): Query result type. Defaults to "INFORMATIVE". Options include: + - "INFORMATIVE" - Neutral information + - "GOOD" - Positive/expected results + - "BAD" - Negative/unexpected results + - "UNKNOWN" - Unknown state + +### Optional Parameters + +- **`resource_group_id`** (str): ID of the resource group to associate the question with + +### Additional Keyword Arguments (**kwargs) + +- **`description`** (str): Description of the question +- **`tags`** (List[str]): List of tags to apply to the question +- **`compliance`** (Dict): Compliance metadata containing: + - `standard` (str): Compliance standard name + - `requirements` (List[str]): List of requirement IDs + - `controls` (List[str]): List of control names +- **`variables`** (List[Dict]): Variable definitions for parameterized queries +- **`showTrend`** (bool): Whether to show trend data for the question results +- **`pollingInterval`** (str): How often to run the queries (e.g., "ONE_HOUR", "ONE_DAY") +- **`integrationDefinitionId`** (str): Integration definition ID if the question is integration-specific + +## Return Value + +Returns a dictionary containing the created question object with fields like: +- `id`: Unique identifier for the question +- `title`: The question title +- `description`: The question description +- `queries`: List of query objects +- `tags`: Applied tags +- And other metadata fields + +## Examples + +### Basic Question + +```python +from jupiterone import JupiterOneClient + +j1_client = JupiterOneClient( + account="your-account-id", + token="your-api-token" +) + +# Create a simple question +question = j1_client.create_question( + title="Find All Open Hosts", + queries=[{ + "query": "FIND Host WITH open=true", + "name": "OpenHosts" + }] +) +``` + +### Question with Multiple Queries + +```python +# Create a question with multiple queries for comprehensive checks +question = j1_client.create_question( + title="Security Compliance Check", + queries=[ + { + "query": "FIND Host WITH open=true", + "name": "OpenHosts", + "resultsAre": "BAD" + }, + { + "query": "FIND User WITH mfaEnabled=false", + "name": "UsersWithoutMFA", + "resultsAre": "BAD" + }, + { + "query": "FIND DataStore WITH encrypted=false", + "name": "UnencryptedDataStores", + "resultsAre": "BAD" + } + ], + description="Comprehensive security compliance check", + tags=["security", "compliance", "audit"] +) +``` + +### Advanced Question with All Options + +```python +# Create a question with all optional parameters +question = j1_client.create_question( + title="AWS Security Audit", + queries=[{ + "query": "FIND aws_instance WITH publicIpAddress!=undefined", + "name": "PublicInstances", + "version": "v1", + "resultsAre": "INFORMATIVE" + }], + resource_group_id="resource-group-123", + description="Audit AWS instances with public IP addresses", + tags=["aws", "security", "network"], + showTrend=True, + pollingInterval="ONE_DAY", + compliance={ + "standard": "CIS", + "requirements": ["2.1", "2.2"], + "controls": ["Network Security"] + }, + variables=[ + { + "name": "environment", + "required": False, + "default": "production" + } + ] +) +``` + +### Minimal Question + +```python +# Create a question with only required parameters +# The query name will default to "Query0" and resultsAre to "INFORMATIVE" +question = j1_client.create_question( + title="Simple Query", + queries=[{ + "query": "FIND User LIMIT 10" + }] +) +``` + +## Error Handling + +The method includes validation for required fields and will raise `ValueError` exceptions for: +- Missing or empty `title` +- Missing or empty `queries` list +- Invalid query format (not a dictionary) +- Missing required `query` field in query objects + +```python +try: + question = j1_client.create_question( + title="", # This will raise an error + queries=[] + ) +except ValueError as e: + print(f"Error: {e}") +``` + +## GraphQL Mutation + +The method uses the following GraphQL mutation internally: + +```graphql +mutation CreateQuestion($question: CreateQuestionInput!) { + createQuestion(question: $question) { + id + title + description + tags + queries { + name + query + version + resultsAre + } + # ... other fields + } +} +``` diff --git a/examples/07_account_parameters_list_example.py b/examples/07_account_parameters_list_example.py new file mode 100644 index 0000000..b7eae33 --- /dev/null +++ b/examples/07_account_parameters_list_example.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +JupiterOne Python SDK - Account Parameter (List Value) Example + +This example demonstrates how to create or update an Account Parameter where the +value is a list of strings. + +Prerequisites (environment variables): +- JUPITERONE_ACCOUNT_ID +- JUPITERONE_API_TOKEN +- (optional) JUPITERONE_URL +- (optional) JUPITERONE_SYNC_URL + +Usage: + python 07_account_parameters_list_example.py +""" + +import os +from jupiterone import JupiterOneClient + + +def setup_client() -> JupiterOneClient: + """Instantiate the JupiterOne client using environment variables.""" + return JupiterOneClient( + account=os.getenv("JUPITERONE_ACCOUNT_ID"), + token=os.getenv("JUPITERONE_API_TOKEN"), + url=os.getenv("JUPITERONE_URL", "https://graphql.us.jupiterone.io"), + sync_url=os.getenv("JUPITERONE_SYNC_URL", "https://api.us.jupiterone.io"), + ) + + +def main() -> None: + print("JupiterOne - Create/Update Account Parameter (List Value)") + print("=" * 70) + + # Configure the parameter name and value + # Name can be anything meaningful to your workflows + parameter_name = os.getenv("J1_LIST_PARAM_NAME", "ENTITY_TYPES_TO_INCLUDE") + + # Example list value requested: ["aws_account", "aws_security_group"] + parameter_value = ["aws_account", "aws_security_group"] + + try: + j1 = setup_client() + + print(f"Creating/Updating parameter '{parameter_name}' with value: {parameter_value}") + result = j1.create_update_parameter( + name=parameter_name, + value=parameter_value, + secret=False, + ) + + # The mutation returns a success flag; fetch the parameter to verify + if result and result.get("setParameter", {}).get("success") is True: + print("āœ“ Parameter upsert reported success") + else: + print("! Parameter upsert did not report success (check details below)") + print(result) + + # Verify by reading it back (non-secret parameters will return the value) + details = j1.get_parameter_details(name=parameter_name) + print("\nFetched parameter details:") + print(details) + + print("\nāœ“ Completed creating/updating list-valued account parameter") + + except Exception as exc: + print(f"āœ— Error: {exc}") + print("\nMake sure you have set the following environment variables:") + print("- JUPITERONE_ACCOUNT_ID") + print("- JUPITERONE_API_TOKEN") + print("- JUPITERONE_URL (optional)") + print("- JUPITERONE_SYNC_URL (optional)") + + +if __name__ == "__main__": + main() + + diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py new file mode 100644 index 0000000..58228bb --- /dev/null +++ b/examples/08_questions_management.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +""" +JupiterOne Python SDK - Questions Management Examples + +This file demonstrates how to: +1. Create questions with single and multiple queries +2. Create questions with various configuration options +3. List existing questions in your account +4. Use questions for security monitoring and compliance +""" + +import os +import json +import time +from jupiterone import JupiterOneClient + +def setup_client(): + """Set up JupiterOne client with credentials.""" + return JupiterOneClient( + account=os.getenv('JUPITERONE_ACCOUNT_ID'), + token=os.getenv('JUPITERONE_API_TOKEN'), + url=os.getenv('JUPITERONE_URL', 'https://graphql.us.jupiterone.io'), + sync_url=os.getenv('JUPITERONE_SYNC_URL', 'https://api.us.jupiterone.io') + ) + +def basic_question_examples(j1): + """Demonstrate basic question creation.""" + + print("=== Basic Question Examples ===\n") + + # 1. Create a simple question + print("1. Creating a simple question:") + try: + question = j1.create_question( + title="Show All Public S3 Buckets", + queries=[{ + "query": "FIND aws_s3_bucket WITH public=true", + "name": "PublicBuckets", + "resultsAre": "BAD" + }], + description="Identifies S3 buckets that are publicly accessible", + tags=["aws", "security", "s3"] + ) + print(f"Created question: {question['title']} (ID: {question['id']})") + print(f"Query: {question['queries'][0]['query']}") + print() + except Exception as e: + print(f"Error creating question: {e}\n") + + # 2. Create a question with minimal parameters + print("2. Creating a minimal question:") + try: + minimal_question = j1.create_question( + title="List Recent Users", + queries=[{ + "query": "FIND User WITH createdOn > date.now - 7 days" + }] + ) + print(f"Created minimal question: {minimal_question['title']}") + print(f"Auto-generated query name: {minimal_question['queries'][0]['name']}") + print() + except Exception as e: + print(f"Error creating minimal question: {e}\n") + +def multi_query_question_examples(j1): + """Demonstrate questions with multiple queries.""" + + print("=== Multi-Query Question Examples ===\n") + + # Create a comprehensive security compliance question + print("1. Creating a comprehensive security compliance question:") + try: + compliance_question = j1.create_question( + title="Security Compliance Dashboard", + queries=[ + { + "query": "FIND User WITH mfaEnabled=false", + "name": "UsersWithoutMFA", + "resultsAre": "BAD" + }, + { + "query": "FIND Host WITH encryptionEnabled=false", + "name": "UnencryptedHosts", + "resultsAre": "BAD" + }, + { + "query": "FIND DataStore WITH public=true", + "name": "PublicDataStores", + "resultsAre": "BAD" + }, + { + "query": "FIND aws_iam_user THAT !HAS aws_iam_access_key", + "name": "UsersWithoutAccessKeys", + "resultsAre": "GOOD" + } + ], + description="Comprehensive security compliance checks across users, hosts, and data stores", + tags=["security", "compliance", "audit", "dashboard"], + showTrend=True + ) + + print(f"Created compliance question: {compliance_question['title']}") + print(f"Number of queries: {len(compliance_question['queries'])}") + for idx, query in enumerate(compliance_question['queries']): + print(f" Query {idx + 1}: {query['name']} - Results are {query['resultsAre']}") + print() + except Exception as e: + print(f"Error creating compliance question: {e}\n") + + # Create an AWS security audit question + print("2. Creating an AWS security audit question:") + try: + aws_audit_question = j1.create_question( + title="AWS Infrastructure Security Audit", + queries=[ + { + "query": "FIND aws_instance WITH publicIpAddress!=undefined", + "name": "PublicInstances", + "version": "v1", + "resultsAre": "INFORMATIVE" + }, + { + "query": "FIND aws_security_group THAT ALLOWS * WITH ipProtocol='tcp' AND fromPort<=22 AND toPort>=22", + "name": "SSHOpenToWorld", + "version": "v1", + "resultsAre": "BAD" + }, + { + "query": "FIND aws_s3_bucket WITH (lifecycleEnabled=false OR lifecycleEnabled=undefined)", + "name": "BucketsWithoutLifecycle", + "version": "v1", + "resultsAre": "INFORMATIVE" + } + ], + description="Audit AWS infrastructure for security best practices", + tags=["aws", "security", "network", "audit"], + pollingInterval="ONE_DAY" + ) + + print(f"Created AWS audit question: {aws_audit_question['title']}") + print(f"Polling interval: {aws_audit_question.get('pollingInterval', 'Not set')}") + print() + except Exception as e: + print(f"Error creating AWS audit question: {e}\n") + +def advanced_question_examples(j1): + """Demonstrate advanced question features.""" + + print("=== Advanced Question Examples ===\n") + + # 1. Question with compliance metadata + print("1. Creating a question with compliance metadata:") + try: + compliance_mapped_question = j1.create_question( + title="CIS AWS Foundations Benchmark 2.3", + queries=[{ + "query": "FIND aws_cloudtrail WITH isMultiRegionTrail!=true", + "name": "SingleRegionTrails", + "resultsAre": "BAD" + }], + description="Ensure CloudTrail is enabled in all regions (CIS AWS Foundations Benchmark 2.3)", + tags=["cis", "aws", "cloudtrail", "compliance"], + compliance={ + "standard": "CIS AWS Foundations Benchmark", + "requirements": ["2.3"], + "controls": ["Logging and Monitoring"] + }, + showTrend=True, + pollingInterval="ONE_HOUR" + ) + + print(f"Created compliance-mapped question: {compliance_mapped_question['title']}") + if 'compliance' in compliance_mapped_question: + print(f"Compliance standard: {compliance_mapped_question['compliance'].get('standard')}") + print() + except Exception as e: + print(f"Error creating compliance-mapped question: {e}\n") + + # 2. Question with variables (parameterized queries) + print("2. Creating a parameterized question with variables:") + try: + parameterized_question = j1.create_question( + title="Environment-Specific Resource Audit", + queries=[{ + "query": "FIND * WITH tag.Environment={{environment}} AND tag.CostCenter={{costCenter}}", + "name": "EnvironmentResources", + "resultsAre": "INFORMATIVE" + }], + description="Audit resources by environment and cost center tags", + tags=["audit", "tagging", "cost-management"], + variables=[ + { + "name": "environment", + "required": True, + "default": "production" + }, + { + "name": "costCenter", + "required": False, + "default": "engineering" + } + ] + ) + + print(f"Created parameterized question: {parameterized_question['title']}") + if 'variables' in parameterized_question: + print(f"Number of variables: {len(parameterized_question['variables'])}") + for var in parameterized_question['variables']: + print(f" Variable: {var['name']} (required: {var.get('required', False)})") + print() + except Exception as e: + print(f"Error creating parameterized question: {e}\n") + +def resource_group_question_examples(j1): + """Demonstrate questions with resource group associations.""" + + print("=== Resource Group Question Examples ===\n") + + # Note: You'll need to have a resource group ID for this example + # This is just a demonstration - replace with your actual resource group ID + resource_group_id = "your-resource-group-id" # Replace with actual ID + + print("1. Creating a question associated with a resource group:") + try: + rg_question = j1.create_question( + title="Production Environment Security Check", + queries=[ + { + "query": "FIND Host WITH tag.Environment='production' AND encrypted!=true", + "name": "UnencryptedProdHosts", + "resultsAre": "BAD" + }, + { + "query": "FIND Database WITH tag.Environment='production' AND backupEnabled!=true", + "name": "ProdDatabasesWithoutBackup", + "resultsAre": "BAD" + } + ], + resource_group_id=resource_group_id, # Associate with resource group + description="Security checks for production environment resources", + tags=["production", "security", "critical"], + pollingInterval="THIRTY_MINUTES" # More frequent polling for production + ) + + print(f"Created resource group question: {rg_question['title']}") + print(f"Resource group ID: {resource_group_id}") + print() + except Exception as e: + print(f"Note: Resource group example requires valid resource group ID\n") + +def list_questions_example(j1): + """Demonstrate listing existing questions.""" + + print("=== List Questions Example ===\n") + + print("Fetching existing questions in the account...") + try: + questions = j1.list_questions() + + print(f"Total questions found: {len(questions)}") + + # Display first 5 questions + for idx, question in enumerate(questions[:5]): + print(f"\nQuestion {idx + 1}:") + print(f" Title: {question['title']}") + print(f" ID: {question['id']}") + print(f" Tags: {', '.join(question.get('tags', []))}") + print(f" Number of queries: {len(question.get('queries', []))}") + if question.get('description'): + print(f" Description: {question['description'][:50]}...") + + if len(questions) > 5: + print(f"\n... and {len(questions) - 5} more questions") + + except Exception as e: + print(f"Error listing questions: {e}") + +def question_use_cases(j1): + """Demonstrate real-world use cases for questions.""" + + print("\n=== Question Use Cases ===\n") + + # Use Case 1: Daily Security Report + print("Use Case 1: Daily Security Report Question") + print("-" * 50) + print("Create a question that runs daily to generate a security report:") + print(""" + question = j1.create_question( + title="Daily Security Report", + queries=[ + {"query": "FIND Finding WITH createdOn > date.now - 1 day", "name": "NewFindings"}, + {"query": "FIND User WITH createdOn > date.now - 1 day", "name": "NewUsers"}, + {"query": "FIND * WITH _class='Vulnerability' AND open=true", "name": "OpenVulnerabilities"} + ], + description="Daily security report showing new findings, users, and open vulnerabilities", + tags=["daily-report", "security"], + pollingInterval="ONE_DAY" + ) + """) + + # Use Case 2: Compliance Monitoring + print("\nUse Case 2: Continuous Compliance Monitoring") + print("-" * 50) + print("Create questions for continuous compliance monitoring:") + print(""" + question = j1.create_question( + title="PCI-DSS Compliance Checks", + queries=[ + {"query": "FIND User WITH privileged=true AND lastAuthenticationOn < date.now - 90 days", "name": "InactivePrivilegedUsers", "resultsAre": "BAD"}, + {"query": "FIND DataStore WITH classification='payment' AND encrypted!=true", "name": "UnencryptedPaymentData", "resultsAre": "BAD"} + ], + compliance={"standard": "PCI-DSS", "requirements": ["8.1.4", "3.4"]}, + pollingInterval="FOUR_HOURS" + ) + """) + + # Use Case 3: Cost Optimization + print("\nUse Case 3: Cloud Cost Optimization") + print("-" * 50) + print("Create questions to identify cost optimization opportunities:") + print(""" + question = j1.create_question( + title="Unused Cloud Resources", + queries=[ + {"query": "FIND aws_instance WITH state='stopped' AND stoppedOn < date.now - 30 days", "name": "LongStoppedInstances"}, + {"query": "FIND aws_ebs_volume THAT !RELATES TO aws_instance", "name": "UnattachedVolumes"}, + {"query": "FIND aws_eip THAT !RELATES TO aws_instance", "name": "UnassociatedElasticIPs"} + ], + tags=["cost-optimization", "aws"], + pollingInterval="ONE_WEEK" + ) + """) + +def main(): + """Run all question management examples.""" + j1 = setup_client() + + print("JupiterOne Questions Management Examples") + print("=" * 50) + print() + + # Run examples + basic_question_examples(j1) + time.sleep(1) # Small delay between API calls + + multi_query_question_examples(j1) + time.sleep(1) + + advanced_question_examples(j1) + time.sleep(1) + + resource_group_question_examples(j1) + time.sleep(1) + + list_questions_example(j1) + + question_use_cases(j1) + + print("\n" + "=" * 50) + print("Examples completed!") + print("\nNote: Remember to set your environment variables:") + print(" - JUPITERONE_ACCOUNT_ID") + print(" - JUPITERONE_API_TOKEN") + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md index 6d5b184..7f8267e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -105,6 +105,30 @@ This directory contains comprehensive examples demonstrating how to use the Jupi - Performance optimization techniques - Data synchronization workflows +### 7. **07_account_parameters_list_example.py** +**Purpose**: Account parameter management +- List all account parameters +- Create/update account parameters +- Fetch parameter details +- Secret parameter handling + +**Key Methods Demonstrated**: +- `list_account_parameters()` - List all parameters +- `create_update_parameter()` - Create or update parameters +- `get_parameter_details()` - Get parameter details + +### 8. **08_questions_management.py** +**Purpose**: Questions creation and management +- Create questions with single and multiple queries +- Configure question properties (tags, compliance, variables) +- Create questions for security monitoring and compliance +- List existing questions in the account +- Advanced question features (parameterization, resource groups) + +**Key Methods Demonstrated**: +- `create_question()` - Create questions with J1QL queries +- `list_questions()` - List all questions in the account + ## šŸš€ Getting Started ### Prerequisites @@ -142,6 +166,12 @@ python 05_alert_rules_and_smartclasses.py # Run advanced operations examples python 06_advanced_operations.py + +# Run account parameters examples +python 07_account_parameters_list_example.py + +# Run questions management examples +python 08_questions_management.py ``` ## šŸ“‹ Example Categories diff --git a/jupiterone/client.py b/jupiterone/client.py index 47a39ba..57adb6b 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -43,6 +43,7 @@ UPDATE_RULE_INSTANCE, EVALUATE_RULE_INSTANCE, QUESTIONS, + CREATE_QUESTION, COMPLIANCE_FRAMEWORK_ITEM, LIST_COLLECTION_RESULTS, GET_RAW_DATA_DOWNLOAD_URL, @@ -1279,6 +1280,101 @@ def list_questions(self): return results + def create_question( + self, + title: str, + queries: List[Dict], + resource_group_id: str = None, + **kwargs + ): + """Creates a new Question in the J1 account. + + Args: + title (str): The title of the question (required) + queries (List[Dict]): List of query objects containing: + - query (str): The J1QL query string + - name (str): Name for the query + - version (str): Query version (defaults to 'v1') + - resultsAre (str): Query result type (defaults to 'INFORMATIVE') + resource_group_id (str, optional): ID of the resource group to associate with + **kwargs: Additional optional parameters: + - description (str): Description of the question + - tags (List[str]): List of tags to apply to the question + - compliance (Dict): Compliance metadata + - variables (List[Dict]): Variable definitions for the queries + - showTrend (bool): Whether to show trend data + - pollingInterval (str): How often to run the queries + - integrationDefinitionId (str): Integration definition ID if applicable + + Returns: + Dict: The created question object + + Example: + question = j1_client.create_question( + title="Security Compliance Check", + queries=[{ + "query": "FIND Host WITH open=true", + "name": "OpenHosts", + "version": "v1", + "resultsAre": "INFORMATIVE" + }], + resource_group_id="resource-group-id", + description="Check for open hosts", + tags=["security", "compliance"] + ) + """ + # Validate required fields + if not title: + raise ValueError("title is required") + if not queries or not isinstance(queries, list) or len(queries) == 0: + raise ValueError("queries must be a non-empty list") + + # Process each query to ensure required fields + processed_queries = [] + for idx, query in enumerate(queries): + if not isinstance(query, dict): + raise ValueError(f"Query at index {idx} must be a dictionary") + if "query" not in query: + raise ValueError(f"Query at index {idx} must have a 'query' field") + + processed_query = { + "query": query["query"], + "name": query.get("name", f"Query{idx}"), + "resultsAre": query.get("resultsAre", "INFORMATIVE") + } + + # Only add version if provided + if "version" in query: + processed_query["version"] = query["version"] + + processed_queries.append(processed_query) + + # Build the question input object + question_input = { + "title": title, + "queries": processed_queries + } + + # Add optional fields from kwargs + if resource_group_id: + question_input["resourceGroupId"] = resource_group_id + + # Add other optional fields if provided + optional_fields = [ + "description", "tags", "compliance", "variables", + "showTrend", "pollingInterval", "integrationDefinitionId" + ] + + for field in optional_fields: + if field in kwargs and kwargs[field] is not None: + question_input[field] = kwargs[field] + + # Execute the GraphQL mutation + variables = {"question": question_input} + response = self._execute_query(CREATE_QUESTION, variables=variables) + + return response["data"]["createQuestion"] + def get_compliance_framework_item_details(self, item_id: str = None): """Fetch Details of a Compliance Framework Requirement configured in J1 account""" variables = {"input": {"id": item_id}} diff --git a/jupiterone/constants.py b/jupiterone/constants.py index 0e9011b..537716b 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -920,6 +920,41 @@ __typename } """ +CREATE_QUESTION = """ + mutation CreateQuestion($question: CreateQuestionInput!) { + createQuestion(question: $question) { + id + title + description + tags + queries { + name + query + version + resultsAre + __typename + } + compliance { + standard + requirements + controls + __typename + } + variables { + name + required + default + __typename + } + accountId + integrationDefinitionId + showTrend + pollingInterval + lastUpdatedTimestamp + __typename + } + } +""" COMPLIANCE_FRAMEWORK_ITEM = """ query complianceFrameworkItem($input: ComplianceFrameworkItemInput!) { complianceFrameworkItem(input: $input) { diff --git a/tests/test_create_question.py b/tests/test_create_question.py new file mode 100644 index 0000000..2034ccd --- /dev/null +++ b/tests/test_create_question.py @@ -0,0 +1,467 @@ +"""Test create_question method""" + +import pytest +import responses +from unittest.mock import Mock, patch +from jupiterone.client import JupiterOneClient +from jupiterone.constants import CREATE_QUESTION + + +class TestCreateQuestion: + """Test create_question method""" + + def setup_method(self): + """Set up test fixtures""" + self.client = JupiterOneClient(account="test-account", token="test-token") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_basic(self, mock_execute): + """Test basic question creation with minimal parameters""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-123", + "title": "Test Question", + "description": "Test description", + "queries": [{ + "name": "Query0", + "query": "FIND Host", + "resultsAre": "INFORMATIVE" + }], + "tags": [], + "accountId": "test-account" + } + } + } + + # Create question + result = self.client.create_question( + title="Test Question", + queries=[{"query": "FIND Host"}] + ) + + # Verify call + mock_execute.assert_called_once() + call_args = mock_execute.call_args + + # Check the mutation was called with correct parameters + self.assertEqual(call_args[0][0], CREATE_QUESTION) + + # Check variables + variables = call_args[1]['variables'] + self.assertEqual(variables['question']['title'], "Test Question") + self.assertEqual(len(variables['question']['queries']), 1) + self.assertEqual(variables['question']['queries'][0]['query'], "FIND Host") + self.assertEqual(variables['question']['queries'][0]['name'], "Query0") + self.assertEqual(variables['question']['queries'][0]['resultsAre'], "INFORMATIVE") + + # Check result + self.assertEqual(result['id'], "question-123") + self.assertEqual(result['title'], "Test Question") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_with_all_options(self, mock_execute): + """Test question creation with all optional parameters""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-456", + "title": "Complex Question", + "description": "Complex description", + "queries": [{ + "name": "NoMFAUsers", + "query": "FIND User WITH mfaEnabled=false", + "version": "v1", + "resultsAre": "BAD" + }], + "tags": ["security", "test"], + "showTrend": True, + "pollingInterval": "ONE_HOUR", + "resourceGroupId": "rg-123" + } + } + } + + # Create question with all options + result = self.client.create_question( + title="Complex Question", + queries=[{ + "query": "FIND User WITH mfaEnabled=false", + "name": "NoMFAUsers", + "version": "v1", + "resultsAre": "BAD" + }], + resource_group_id="rg-123", + description="Complex description", + tags=["security", "test"], + showTrend=True, + pollingInterval="ONE_HOUR" + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + self.assertEqual(question_input['title'], "Complex Question") + self.assertEqual(question_input['resourceGroupId'], "rg-123") + self.assertEqual(question_input['description'], "Complex description") + self.assertEqual(question_input['tags'], ["security", "test"]) + self.assertEqual(question_input['showTrend'], True) + self.assertEqual(question_input['pollingInterval'], "ONE_HOUR") + + # Check result + self.assertEqual(result['id'], "question-456") + self.assertEqual(result['showTrend'], True) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_with_compliance(self, mock_execute): + """Test question creation with compliance metadata""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-789", + "title": "Compliance Question", + "compliance": { + "standard": "CIS", + "requirements": ["2.1", "2.2"], + "controls": ["Network Security"] + } + } + } + } + + # Create question with compliance + result = self.client.create_question( + title="Compliance Question", + queries=[{ + "query": "FIND Host WITH open=true", + "name": "OpenHosts", + "resultsAre": "BAD" + }], + compliance={ + "standard": "CIS", + "requirements": ["2.1", "2.2"], + "controls": ["Network Security"] + } + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + self.assertEqual(question_input['compliance']['standard'], "CIS") + self.assertEqual(question_input['compliance']['requirements'], ["2.1", "2.2"]) + self.assertEqual(question_input['compliance']['controls'], ["Network Security"]) + + # Check result + self.assertEqual(result['compliance']['standard'], "CIS") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_with_variables(self, mock_execute): + """Test question creation with variables""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-101", + "title": "Variable Question", + "variables": [ + { + "name": "environment", + "required": True, + "default": "production" + } + ] + } + } + } + + # Create question with variables + result = self.client.create_question( + title="Variable Question", + queries=[{ + "query": "FIND * WITH tag.Environment={{environment}}", + "name": "EnvResources" + }], + variables=[ + { + "name": "environment", + "required": True, + "default": "production" + } + ] + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + self.assertEqual(len(question_input['variables']), 1) + self.assertEqual(question_input['variables'][0]['name'], "environment") + self.assertEqual(question_input['variables'][0]['required'], True) + self.assertEqual(question_input['variables'][0]['default'], "production") + + # Check result + self.assertEqual(len(result['variables']), 1) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_multiple_queries(self, mock_execute): + """Test question creation with multiple queries""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-multi", + "title": "Multi Query Question", + "queries": [ + { + "name": "Query1", + "query": "FIND Host WITH open=true", + "resultsAre": "BAD" + }, + { + "name": "Query2", + "query": "FIND User WITH mfaEnabled=false", + "resultsAre": "BAD" + } + ] + } + } + } + + # Create question with multiple queries + result = self.client.create_question( + title="Multi Query Question", + queries=[ + { + "query": "FIND Host WITH open=true", + "name": "Query1", + "resultsAre": "BAD" + }, + { + "query": "FIND User WITH mfaEnabled=false", + "name": "Query2", + "resultsAre": "BAD" + } + ] + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + self.assertEqual(len(question_input['queries']), 2) + self.assertEqual(question_input['queries'][0]['name'], "Query1") + self.assertEqual(question_input['queries'][1]['name'], "Query2") + + # Check result + self.assertEqual(len(result['queries']), 2) + + def test_create_question_validation_title_required(self): + """Test validation that title is required""" + with pytest.raises(ValueError, match="title is required"): + self.client.create_question(title="", queries=[{"query": "FIND Host"}]) + + with pytest.raises(ValueError, match="title is required"): + self.client.create_question(title=None, queries=[{"query": "FIND Host"}]) + + def test_create_question_validation_queries_required(self): + """Test validation that queries are required""" + with pytest.raises(ValueError, match="queries must be a non-empty list"): + self.client.create_question(title="Test", queries=[]) + + with pytest.raises(ValueError, match="queries must be a non-empty list"): + self.client.create_question(title="Test", queries=None) + + def test_create_question_validation_query_format(self): + """Test validation of query format""" + with pytest.raises(ValueError, match="must be a dictionary"): + self.client.create_question(title="Test", queries=["invalid"]) + + with pytest.raises(ValueError, match="must be a dictionary"): + self.client.create_question(title="Test", queries=[123]) + + def test_create_question_validation_query_field_required(self): + """Test validation that query field is required in each query""" + with pytest.raises(ValueError, match="must have a 'query' field"): + self.client.create_question(title="Test", queries=[{"name": "Test"}]) + + with pytest.raises(ValueError, match="must have a 'query' field"): + self.client.create_question(title="Test", queries=[{"version": "v1"}]) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_auto_naming(self, mock_execute): + """Test automatic query naming when not provided""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-auto", + "title": "Auto Naming Test", + "queries": [ + {"name": "Query0", "query": "FIND Host"}, + {"name": "Query1", "query": "FIND User"} + ] + } + } + } + + # Create question without query names + result = self.client.create_question( + title="Auto Naming Test", + queries=[ + {"query": "FIND Host"}, + {"query": "FIND User"} + ] + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + self.assertEqual(question_input['queries'][0]['name'], "Query0") + self.assertEqual(question_input['queries'][1]['name'], "Query1") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_results_are_default(self, mock_execute): + """Test that resultsAre defaults to INFORMATIVE when not provided""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-default", + "title": "Default Results Test", + "queries": [{"name": "Query0", "query": "FIND Host"}] + } + } + } + + # Create question without resultsAre + result = self.client.create_question( + title="Default Results Test", + queries=[{"query": "FIND Host"}] + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + self.assertEqual(question_input['queries'][0]['resultsAre'], "INFORMATIVE") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_version_optional(self, mock_execute): + """Test that version is only included when provided""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-version", + "title": "Version Test", + "queries": [{"name": "Query0", "query": "FIND Host"}] + } + } + } + + # Create question without version + result = self.client.create_question( + title="Version Test", + queries=[{"query": "FIND Host"}] + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + # Version should not be in the query if not provided + self.assertNotIn('version', question_input['queries'][0]) + + # Create question with version + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-version2", + "title": "Version Test 2", + "queries": [{"name": "Query0", "query": "FIND Host", "version": "v1"}] + } + } + } + + result = self.client.create_question( + title="Version Test 2", + queries=[{"query": "FIND Host", "version": "v1"}] + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + # Version should be included when provided + self.assertEqual(question_input['queries'][0]['version'], "v1") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_optional_fields_handling(self, mock_execute): + """Test that optional fields are only included when provided and not None""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-optional", + "title": "Optional Fields Test" + } + } + } + + # Create question with some None values + result = self.client.create_question( + title="Optional Fields Test", + queries=[{"query": "FIND Host"}], + description=None, # Should not be included + tags=None, # Should not be included + showTrend=False, # Should be included + pollingInterval="ONE_DAY" # Should be included + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + # None values should not be included + self.assertNotIn('description', question_input) + self.assertNotIn('tags', question_input) + + # Non-None values should be included + self.assertEqual(question_input['showTrend'], False) + self.assertEqual(question_input['pollingInterval'], "ONE_DAY") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_create_question_integration_definition_id(self, mock_execute): + """Test question creation with integration definition ID""" + # Mock response + mock_execute.return_value = { + "data": { + "createQuestion": { + "id": "question-integration", + "title": "Integration Question", + "integrationDefinitionId": "integration-123" + } + } + } + + # Create question with integration definition ID + result = self.client.create_question( + title="Integration Question", + queries=[{"query": "FIND aws_instance"}], + integrationDefinitionId="integration-123" + ) + + # Check variables + variables = mock_execute.call_args[1]['variables'] + question_input = variables['question'] + + self.assertEqual(question_input['integrationDefinitionId'], "integration-123") + + # Check result + self.assertEqual(result['integrationDefinitionId'], "integration-123") From 72ee82f4b51b8823176de74b27c406a938619535 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Thu, 14 Aug 2025 14:34:24 -0600 Subject: [PATCH 02/22] Update 08_questions_management.py --- examples/08_questions_management.py | 50 +++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py index 58228bb..b24628c 100644 --- a/examples/08_questions_management.py +++ b/examples/08_questions_management.py @@ -7,6 +7,10 @@ 2. Create questions with various configuration options 3. List existing questions in your account 4. Use questions for security monitoring and compliance + +NOTE: This example includes robust error handling for the compliance field +to handle potential GraphQL schema mismatches where compliance data might +be returned as a list instead of a dictionary. """ import os @@ -150,6 +154,10 @@ def advanced_question_examples(j1): # 1. Question with compliance metadata print("1. Creating a question with compliance metadata:") + # Note: The compliance field access has been made robust to handle potential + # GraphQL schema mismatches where compliance data might be returned as a list + # instead of a dictionary. This was causing the original error: + # "'list' object has no attribute 'get'" try: compliance_mapped_question = j1.create_question( title="CIS AWS Foundations Benchmark 2.3", @@ -170,11 +178,47 @@ def advanced_question_examples(j1): ) print(f"Created compliance-mapped question: {compliance_mapped_question['title']}") - if 'compliance' in compliance_mapped_question: - print(f"Compliance standard: {compliance_mapped_question['compliance'].get('standard')}") + + # Debug: Show the entire response structure + print(f"Full question response keys: {list(compliance_mapped_question.keys())}") + print(f"Question ID: {compliance_mapped_question.get('id', 'No ID')}") + + try: + if 'compliance' in compliance_mapped_question: + compliance_data = compliance_mapped_question['compliance'] + # Debug: Show the actual structure + print(f"Compliance data structure: {type(compliance_data)}") + print(f"Compliance data content: {compliance_data}") + + # Handle both list and dictionary responses for compliance + if isinstance(compliance_data, dict): + print(f"Compliance standard: {compliance_data.get('standard', 'Not specified')}") + if 'requirements' in compliance_data: + reqs = compliance_data['requirements'] + if isinstance(reqs, list): + print(f"Compliance requirements: {', '.join(map(str, reqs))}") + else: + print(f"Compliance requirements (unexpected type): {type(reqs)} - {reqs}") + elif isinstance(compliance_data, list): + print(f"Compliance data returned as list with {len(compliance_data)} items") + # If it's a list, try to access the first item if it exists + if compliance_data and isinstance(compliance_data[0], dict): + print(f"First compliance item: {compliance_data[0]}") + else: + print(f"Compliance data type: {type(compliance_data)}") + else: + print("No compliance field found in response") + except Exception as compliance_error: + print(f"Error accessing compliance data: {compliance_error}") + print(f"Compliance field type: {type(compliance_mapped_question.get('compliance', 'Not present'))}") + # Show more debugging info + print(f"Full response for debugging: {compliance_mapped_question}") print() except Exception as e: - print(f"Error creating compliance-mapped question: {e}\n") + print(f"Error creating compliance-mapped question: {e}") + print(f"Error type: {type(e).__name__}") + print(f"Error details: {str(e)}") + print() # 2. Question with variables (parameterized queries) print("2. Creating a parameterized question with variables:") From c9d201e0ca22f984d3f852d6f193e16c31f23cee Mon Sep 17 00:00:00 2001 From: Colin Blumer Date: Thu, 14 Aug 2025 14:40:17 -0600 Subject: [PATCH 03/22] Potential fix for code scanning alert no. 115: Unused import Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- examples/08_questions_management.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py index b24628c..fbd97b7 100644 --- a/examples/08_questions_management.py +++ b/examples/08_questions_management.py @@ -14,7 +14,6 @@ """ import os -import json import time from jupiterone import JupiterOneClient From 24e9fdefe88b70a860f2266cd392689738065391 Mon Sep 17 00:00:00 2001 From: Colin Blumer Date: Thu, 14 Aug 2025 14:40:43 -0600 Subject: [PATCH 04/22] Potential fix for code scanning alert no. 116: Unused import Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/test_create_question.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_create_question.py b/tests/test_create_question.py index 2034ccd..02473b5 100644 --- a/tests/test_create_question.py +++ b/tests/test_create_question.py @@ -1,7 +1,6 @@ """Test create_question method""" import pytest -import responses from unittest.mock import Mock, patch from jupiterone.client import JupiterOneClient from jupiterone.constants import CREATE_QUESTION From dc1dadfa90cbf87e7b6a7cd928fc86553ce4b283 Mon Sep 17 00:00:00 2001 From: Colin Blumer Date: Thu, 14 Aug 2025 14:46:28 -0600 Subject: [PATCH 05/22] Potential fix for code scanning alert no. 113: Unused local variable Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/test_create_question.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_create_question.py b/tests/test_create_question.py index 02473b5..ffd8f37 100644 --- a/tests/test_create_question.py +++ b/tests/test_create_question.py @@ -414,7 +414,7 @@ def test_create_question_optional_fields_handling(self, mock_execute): } # Create question with some None values - result = self.client.create_question( + self.client.create_question( title="Optional Fields Test", queries=[{"query": "FIND Host"}], description=None, # Should not be included From a8e952f8998e1373e14056f92bd35fa81d900bab Mon Sep 17 00:00:00 2001 From: Colin Blumer Date: Thu, 14 Aug 2025 18:22:10 -0600 Subject: [PATCH 06/22] Potential fix for code scanning alert no. 117: Unused import Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/test_create_question.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_create_question.py b/tests/test_create_question.py index ffd8f37..bb29c24 100644 --- a/tests/test_create_question.py +++ b/tests/test_create_question.py @@ -1,7 +1,7 @@ """Test create_question method""" import pytest -from unittest.mock import Mock, patch +from unittest.mock import patch from jupiterone.client import JupiterOneClient from jupiterone.constants import CREATE_QUESTION From 9a93211491144b6ed97e0abae9c4139245766897 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Thu, 14 Aug 2025 18:24:25 -0600 Subject: [PATCH 07/22] Update test_create_question.py --- tests/test_create_question.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_create_question.py b/tests/test_create_question.py index 2034ccd..db1bc50 100644 --- a/tests/test_create_question.py +++ b/tests/test_create_question.py @@ -310,7 +310,7 @@ def test_create_question_auto_naming(self, mock_execute): } # Create question without query names - result = self.client.create_question( + self.client.create_question( title="Auto Naming Test", queries=[ {"query": "FIND Host"}, @@ -340,7 +340,7 @@ def test_create_question_results_are_default(self, mock_execute): } # Create question without resultsAre - result = self.client.create_question( + self.client.create_question( title="Default Results Test", queries=[{"query": "FIND Host"}] ) @@ -389,7 +389,7 @@ def test_create_question_version_optional(self, mock_execute): } } - result = self.client.create_question( + self.client.create_question( title="Version Test 2", queries=[{"query": "FIND Host", "version": "v1"}] ) From 594241a0dcd3b1e98f036d93ee2dd4da7c991c40 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Thu, 14 Aug 2025 18:27:16 -0600 Subject: [PATCH 08/22] Update test_create_question.py --- tests/test_create_question.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_create_question.py b/tests/test_create_question.py index 97fc43b..eb87ca0 100644 --- a/tests/test_create_question.py +++ b/tests/test_create_question.py @@ -365,7 +365,7 @@ def test_create_question_version_optional(self, mock_execute): } # Create question without version - result = self.client.create_question( + self.client.create_question( title="Version Test", queries=[{"query": "FIND Host"}] ) From 4a097e83f80d6be045d533faf64dfd08be5708a1 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Thu, 14 Aug 2025 18:46:41 -0600 Subject: [PATCH 09/22] add list_questions() documentation --- examples/README.md | 44 ++++ examples/examples.py | 78 ++++++ jupiterone/client.py | 2 +- tests/test_list_questions.py | 477 +++++++++++++++++++++++++++++++++++ 4 files changed, 600 insertions(+), 1 deletion(-) create mode 100644 tests/test_list_questions.py diff --git a/examples/README.md b/examples/README.md index 7f8267e..722a484 100644 --- a/examples/README.md +++ b/examples/README.md @@ -129,6 +129,24 @@ This directory contains comprehensive examples demonstrating how to use the Jupi - `create_question()` - Create questions with J1QL queries - `list_questions()` - List all questions in the account +### 9. **examples.py** +**Purpose**: Comprehensive examples of all major SDK methods +- Client setup and basic operations +- Entity and relationship management +- Integration and sync job operations +- Alert rules and SmartClass operations +- Questions management and analysis +- Account parameter operations + +**Key Methods Demonstrated**: +- All major SDK methods including: +- `list_questions()` - List and analyze all questions in the account +- `create_question()` - Create questions with various configurations +- Entity lifecycle management methods +- Relationship management methods +- Integration and sync job methods +- Alert rule and SmartClass methods + ## šŸš€ Getting Started ### Prerequisites @@ -184,6 +202,13 @@ python 08_questions_management.py - Time-based queries - Complex multi-step queries +### šŸ“Š Questions Management +- Question creation with J1QL queries +- Question listing and analysis +- Compliance metadata management +- Question categorization and filtering +- Question lifecycle management + ### šŸ—ļø Entity Management - Entity creation with various property types - Entity updates and modifications @@ -254,6 +279,25 @@ j1 = JupiterOneClient( ) ``` +### Questions Analysis +Examples show how to analyze questions data: +```python +# List all questions +questions = j1.list_questions() + +# Analyze by compliance standards +compliance_standards = {} +for question in questions: + if 'compliance' in question and question['compliance']: + compliance = question['compliance'] + if isinstance(compliance, dict) and 'standard' in compliance: + standard = compliance['standard'] + compliance_standards[standard] = compliance_standards.get(standard, 0) + 1 + +# Find questions by tags +security_questions = [q for q in questions if 'tags' in q and q['tags'] and any('security' in tag.lower() for tag in q['tags'])] +``` + ## šŸ“ Notes ### Placeholder Values diff --git a/examples/examples.py b/examples/examples.py index 31cc7fa..ef1e4be 100644 --- a/examples/examples.py +++ b/examples/examples.py @@ -568,3 +568,81 @@ r = j1.create_update_parameter(name="ParameterName", value="stored_value", secret=False) print(json.dumps(r, indent=1)) + +# list_questions +list_questions_r = j1.list_questions() +print("list_questions()") +print(f"Total questions found: {len(list_questions_r)}") +print(json.dumps(list_questions_r[:2], indent=1)) # Show first 2 questions + +# list_questions with filtering (if supported) +# Note: The current implementation doesn't support filtering parameters, +# but the GraphQL schema shows support for searchQuery, tags, etc. +print("\nlist_questions() - Sample question details:") +if list_questions_r: + sample_question = list_questions_r[0] + print(f" Title: {sample_question.get('title', 'No title')}") + print(f" ID: {sample_question.get('id', 'No ID')}") + print(f" Description: {sample_question.get('description', 'No description')}") + print(f" Tags: {sample_question.get('tags', [])}") + print(f" Number of queries: {len(sample_question.get('queries', []))}") + if sample_question.get('queries'): + for i, query in enumerate(sample_question['queries']): + print(f" Query {i+1}: {query.get('name', 'Unnamed')} - {query.get('query', 'No query')[:50]}...") +else: + print(" No questions found in the account") + +# list_questions - analyze question types and compliance +print("\nlist_questions() - Analysis:") +if list_questions_r: + # Count questions by compliance standard + compliance_standards = {} + question_types = {} + + for question in list_questions_r: + # Analyze compliance standards + if 'compliance' in question and question['compliance']: + compliance = question['compliance'] + if isinstance(compliance, dict) and 'standard' in compliance: + standard = compliance['standard'] + compliance_standards[standard] = compliance_standards.get(standard, 0) + 1 + + # Analyze question types by tags + if 'tags' in question and question['tags']: + for tag in question['tags']: + question_types[tag] = question_types.get(tag, 0) + 1 + + print(f" Total questions: {len(list_questions_r)}") + print(f" Compliance standards found: {len(compliance_standards)}") + if compliance_standards: + print(" Standards:") + for standard, count in compliance_standards.items(): + print(f" {standard}: {count} questions") + + print(f" Question categories (by tags): {len(question_types)}") + if question_types: + print(" Top categories:") + sorted_tags = sorted(question_types.items(), key=lambda x: x[1], reverse=True)[:5] + for tag, count in sorted_tags: + print(f" {tag}: {count} questions") + +# list_questions - find specific types of questions +print("\nlist_questions() - Finding specific questions:") +if list_questions_r: + # Find security-related questions + security_questions = [q for q in list_questions_r if 'tags' in q and q['tags'] and any('security' in tag.lower() for tag in q['tags'])] + print(f" Security-related questions: {len(security_questions)}") + + # Find compliance-related questions + compliance_questions = [q for q in list_questions_r if 'compliance' in q and q['compliance']] + print(f" Compliance-related questions: {len(compliance_questions)}") + + # Find questions with variables + variable_questions = [q for q in list_questions_r if 'variables' in q and q['variables']] + print(f" Questions with variables: {len(variable_questions)}") + + # Find questions with polling enabled + polling_questions = [q for q in list_questions_r if 'pollingInterval' in q and q['pollingInterval'] and q['pollingInterval'] != 'DISABLED'] + print(f" Questions with polling enabled: {len(polling_questions)}") + +print() diff --git a/jupiterone/client.py b/jupiterone/client.py index 57adb6b..9ac677d 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -1247,7 +1247,7 @@ def fetch_downloaded_evaluation_results(self, download_url: str = None): return e def list_questions(self): - """List all defined Questions configured in J1 account Questions Library""" + """List all defined Questions configured in J1 Account Questions Library""" results = [] data = { diff --git a/tests/test_list_questions.py b/tests/test_list_questions.py new file mode 100644 index 0000000..89554f3 --- /dev/null +++ b/tests/test_list_questions.py @@ -0,0 +1,477 @@ +"""Test list_questions method""" + +import pytest +from unittest.mock import patch, Mock +from jupiterone.client import JupiterOneClient +from jupiterone.constants import QUESTIONS + + +class TestListQuestions: + """Test list_questions method""" + + def setup_method(self): + """Set up test fixtures""" + self.client = JupiterOneClient(account="test-account", token="test-token") + + @patch('jupiterone.client.requests.post') + def test_list_questions_basic(self, mock_post): + """Test basic questions listing with single page""" + # Mock response for single page + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Test Question 1", + "description": "Test description 1", + "tags": ["test", "security"], + "queries": [{ + "name": "Query1", + "query": "FIND Host", + "version": "v1", + "resultsAre": "INFORMATIVE" + }], + "compliance": { + "standard": "CIS", + "requirements": ["2.1", "2.2"], + "controls": ["Network Security"] + }, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + }, + { + "id": "question-2", + "title": "Test Question 2", + "description": "Test description 2", + "tags": ["compliance", "audit"], + "queries": [{ + "name": "Query2", + "query": "FIND User WITH mfaEnabled=false", + "version": "v1", + "resultsAre": "BAD" + }], + "compliance": None, + "variables": [ + { + "name": "environment", + "required": True, + "default": "production" + } + ], + "accountId": "test-account", + "showTrend": True, + "pollingInterval": "DISABLED", + "lastUpdatedTimestamp": "2024-01-02T00:00:00Z" + } + ], + "totalHits": 2, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions + result = self.client.list_questions() + + # Verify result + assert len(result) == 2 + assert result[0]['id'] == "question-1" + assert result[0]['title'] == "Test Question 1" + assert result[0]['tags'] == ["test", "security"] + assert result[1]['id'] == "question-2" + assert result[1]['title'] == "Test Question 2" + assert result[1]['tags'] == ["compliance", "audit"] + + # Verify API call + mock_post.assert_called_once() + call_args = mock_post.call_args + + # Check the query was called with correct parameters + assert call_args[1]['json']['query'] == QUESTIONS + assert call_args[1]['json']['flags']['variableResultSize'] is True + + @patch('jupiterone.client.requests.post') + def test_list_questions_with_pagination(self, mock_post): + """Test questions listing with multiple pages""" + # Mock first page response + first_response = Mock() + first_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Test Question 1", + "tags": ["test"], + "queries": [{"name": "Query1", "query": "FIND Host"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 3, + "pageInfo": { + "endCursor": "cursor-1", + "hasNextPage": True + } + } + } + } + + # Mock second page response + second_response = Mock() + second_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-2", + "title": "Test Question 2", + "tags": ["security"], + "queries": [{"name": "Query2", "query": "FIND User"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-02T00:00:00Z" + }, + { + "id": "question-3", + "title": "Test Question 3", + "tags": ["compliance"], + "queries": [{"name": "Query3", "query": "FIND Finding"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-03T00:00:00Z" + } + ], + "totalHits": 3, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + + # Set up mock to return different responses for each call + mock_post.side_effect = [first_response, second_response] + + # Call list_questions + result = self.client.list_questions() + + # Verify result + assert len(result) == 3 + assert result[0]['id'] == "question-1" + assert result[1]['id'] == "question-2" + assert result[2]['id'] == "question-3" + + # Verify API calls (2 calls for 2 pages) + assert mock_post.call_count == 2 + + # Check first call + first_call = mock_post.call_args_list[0] + assert first_call[1]['json']['query'] == QUESTIONS + assert first_call[1]['json']['flags']['variableResultSize'] is True + + # Check second call (with cursor) + second_call = mock_post.call_args_list[1] + assert second_call[1]['json']['query'] == QUESTIONS + assert second_call[1]['json']['variables']['cursor'] == "cursor-1" + assert second_call[1]['json']['flags']['variableResultSize'] is True + + @patch('jupiterone.client.requests.post') + def test_list_questions_empty_response(self, mock_post): + """Test questions listing with empty response""" + # Mock empty response + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [], + "totalHits": 0, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions + result = self.client.list_questions() + + # Verify result + assert len(result) == 0 + assert result == [] + + # Verify API call + mock_post.assert_called_once() + + @patch('jupiterone.client.requests.post') + def test_list_questions_with_compliance_data(self, mock_post): + """Test questions listing with compliance metadata""" + # Mock response with compliance data + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "CIS Compliance Check", + "tags": ["cis", "compliance"], + "queries": [{"name": "CISQuery", "query": "FIND Host WITH encrypted=false"}], + "compliance": { + "standard": "CIS AWS Foundations", + "requirements": ["2.1", "2.2", "2.3"], + "controls": ["Data Protection", "Network Security"] + }, + "variables": [], + "accountId": "test-account", + "showTrend": True, + "pollingInterval": "ONE_HOUR", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 1, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions + result = self.client.list_questions() + + # Verify result + assert len(result) == 1 + question = result[0] + assert question['id'] == "question-1" + assert question['title'] == "CIS Compliance Check" + assert question['tags'] == ["cis", "compliance"] + + # Verify compliance data + compliance = question['compliance'] + assert compliance['standard'] == "CIS AWS Foundations" + assert compliance['requirements'] == ["2.1", "2.2", "2.3"] + assert compliance['controls'] == ["Data Protection", "Network Security"] + + @patch('jupiterone.client.requests.post') + def test_list_questions_with_variables(self, mock_post): + """Test questions listing with variable definitions""" + # Mock response with variables + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Environment-Specific Query", + "tags": ["environment", "variables"], + "queries": [{"name": "EnvQuery", "query": "FIND * WITH tag.Environment={{env}}"}], + "compliance": None, + "variables": [ + { + "name": "env", + "required": True, + "default": "production" + }, + { + "name": "region", + "required": False, + "default": "us-east-1" + } + ], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "DISABLED", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 1, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions + result = self.client.list_questions() + + # Verify result + assert len(result) == 1 + question = result[0] + assert question['id'] == "question-1" + assert question['title'] == "Environment-Specific Query" + + # Verify variables + variables = question['variables'] + assert len(variables) == 2 + assert variables[0]['name'] == "env" + assert variables[0]['required'] is True + assert variables[0]['default'] == "production" + assert variables[1]['name'] == "region" + assert variables[1]['required'] is False + assert variables[1]['default'] == "us-east-1" + + @patch('jupiterone.client.requests.post') + def test_list_questions_with_polling_intervals(self, mock_post): + """Test questions listing with different polling intervals""" + # Mock response with various polling intervals + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Daily Check", + "tags": ["daily"], + "queries": [{"name": "DailyQuery", "query": "FIND Finding"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + }, + { + "id": "question-2", + "title": "Hourly Check", + "tags": ["hourly"], + "queries": [{"name": "HourlyQuery", "query": "FIND User"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": True, + "pollingInterval": "ONE_HOUR", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + }, + { + "id": "question-3", + "title": "Disabled Check", + "tags": ["disabled"], + "queries": [{"name": "DisabledQuery", "query": "FIND Host"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "DISABLED", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 3, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions + result = self.client.list_questions() + + # Verify result + assert len(result) == 3 + + # Verify polling intervals + assert result[0]['pollingInterval'] == "ONE_DAY" + assert result[1]['pollingInterval'] == "ONE_HOUR" + assert result[2]['pollingInterval'] == "DISABLED" + + # Verify showTrend settings + assert result[0]['showTrend'] is False + assert result[1]['showTrend'] is True + assert result[2]['showTrend'] is False + + @patch('jupiterone.client.requests.post') + def test_list_questions_error_handling(self, mock_post): + """Test questions listing with error handling""" + # Mock error response + mock_response = Mock() + mock_response.json.return_value = { + "errors": [ + { + "message": "Unauthorized access", + "extensions": {"code": "UNAUTHORIZED"} + } + ] + } + mock_post.return_value = mock_response + + # Call list_questions and expect it to handle the error gracefully + # The method should return an empty list or raise an exception + with pytest.raises(Exception): + self.client.list_questions() + + @patch('jupiterone.client.requests.post') + def test_list_questions_malformed_response(self, mock_post): + """Test questions listing with malformed response""" + # Mock malformed response + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + # Missing required fields + "questions": [ + { + "id": "question-1", + # Missing title + "tags": ["test"] + # Missing other required fields + } + ] + } + } + } + mock_post.return_value = mock_response + + # Call list_questions + result = self.client.list_questions() + + # Verify result (should still work with missing fields) + assert len(result) == 1 + question = result[0] + assert question['id'] == "question-1" + assert question['tags'] == ["test"] + # Missing fields should be None or not present + assert 'title' not in question or question['title'] is None + + def test_list_questions_method_exists(self): + """Test that list_questions method exists and is callable""" + assert hasattr(self.client, 'list_questions') + assert callable(self.client.list_questions) + + def test_list_questions_docstring(self): + """Test that list_questions method has proper documentation""" + method = getattr(self.client, 'list_questions') + assert method.__doc__ is not None + assert "List all defined Questions" in method.__doc__ + assert "J1 account Questions Library" in method.__doc__ From 750e6e97d76d44fb4e45f7b60ffaf108e7f76efc Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Thu, 14 Aug 2025 18:59:02 -0600 Subject: [PATCH 10/22] add keyword search and tag filtering to list_questions() --- examples/08_questions_management.py | 34 +++ examples/README.md | 20 +- examples/examples.py | 36 +-- jupiterone/client.py | 46 +++- tests/test_list_questions.py | 388 ++++++++++++++++++++++++++++ 5 files changed, 502 insertions(+), 22 deletions(-) diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py index fbd97b7..64f359f 100644 --- a/examples/08_questions_management.py +++ b/examples/08_questions_management.py @@ -315,6 +315,40 @@ def list_questions_example(j1): if len(questions) > 5: print(f"\n... and {len(questions) - 5} more questions") + # Demonstrate filtering capabilities + print("\n=== Filtering Examples ===") + + # Search by content + print("\n1. Searching for security-related questions:") + security_questions = j1.list_questions(search_query="security") + print(f" Found {len(security_questions)} questions with 'security' in title/description") + if security_questions: + print(f" Example: {security_questions[0]['title']}") + + # Filter by tags + print("\n2. Filtering by compliance tags:") + compliance_questions = j1.list_questions(tags=["compliance"]) + print(f" Found {len(compliance_questions)} questions tagged with 'compliance'") + if compliance_questions: + print(f" Example: {compliance_questions[0]['title']}") + + # Combine search and tags + print("\n3. Combining search and tags:") + security_compliance = j1.list_questions( + search_query="encryption", + tags=["security", "compliance"] + ) + print(f" Found {len(security_compliance)} questions matching both criteria") + if security_compliance: + print(f" Example: {security_compliance[0]['title']}") + + # Search for specific compliance standards + print("\n4. Searching for CIS compliance questions:") + cis_questions = j1.list_questions(search_query="CIS") + print(f" Found {len(cis_questions)} questions related to CIS") + if cis_questions: + print(f" Example: {cis_questions[0]['title']}") + except Exception as e: print(f"Error listing questions: {e}") diff --git a/examples/README.md b/examples/README.md index 722a484..a50802d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -204,7 +204,9 @@ python 08_questions_management.py ### šŸ“Š Questions Management - Question creation with J1QL queries -- Question listing and analysis +- Question listing and analysis with filtering options +- Search questions by title/description using `search_query` parameter +- Filter questions by tags using `tags` parameter - Compliance metadata management - Question categorization and filtering - Question lifecycle management @@ -280,11 +282,25 @@ j1 = JupiterOneClient( ``` ### Questions Analysis -Examples show how to analyze questions data: +Examples show how to analyze questions data with filtering: ```python # List all questions questions = j1.list_questions() +# Search questions by content +security_questions = j1.list_questions(search_query="security") +encryption_questions = j1.list_questions(search_query="encryption") + +# Filter questions by tags +compliance_questions = j1.list_questions(tags=["compliance"]) +cis_questions = j1.list_questions(tags=["cis", "aws"]) + +# Combine search and tags +security_compliance = j1.list_questions( + search_query="encryption", + tags=["security", "compliance"] +) + # Analyze by compliance standards compliance_standards = {} for question in questions: diff --git a/examples/examples.py b/examples/examples.py index ef1e4be..71c6ffa 100644 --- a/examples/examples.py +++ b/examples/examples.py @@ -575,22 +575,26 @@ print(f"Total questions found: {len(list_questions_r)}") print(json.dumps(list_questions_r[:2], indent=1)) # Show first 2 questions -# list_questions with filtering (if supported) -# Note: The current implementation doesn't support filtering parameters, -# but the GraphQL schema shows support for searchQuery, tags, etc. -print("\nlist_questions() - Sample question details:") -if list_questions_r: - sample_question = list_questions_r[0] - print(f" Title: {sample_question.get('title', 'No title')}") - print(f" ID: {sample_question.get('id', 'No ID')}") - print(f" Description: {sample_question.get('description', 'No description')}") - print(f" Tags: {sample_question.get('tags', [])}") - print(f" Number of queries: {len(sample_question.get('queries', []))}") - if sample_question.get('queries'): - for i, query in enumerate(sample_question['queries']): - print(f" Query {i+1}: {query.get('name', 'Unnamed')} - {query.get('query', 'No query')[:50]}...") -else: - print(" No questions found in the account") +# list_questions with search query +print("\nlist_questions() - With search query:") +security_questions = j1.list_questions(search_query="security") +print(f"Security-related questions found: {len(security_questions)}") +if security_questions: + print(f" First security question: {security_questions[0].get('title', 'No title')}") + +# list_questions with tags filter +print("\nlist_questions() - With tags filter:") +compliance_questions = j1.list_questions(tags=["compliance"]) +print(f"Compliance-tagged questions found: {len(compliance_questions)}") +if compliance_questions: + print(f" First compliance question: {compliance_questions[0].get('title', 'No title')}") + +# list_questions with combined search and tags +print("\nlist_questions() - With search and tags:") +security_compliance = j1.list_questions(search_query="encryption", tags=["security", "compliance"]) +print(f"Security/compliance encryption questions found: {len(security_compliance)}") +if security_compliance: + print(f" First matching question: {security_compliance[0].get('title', 'No title')}") # list_questions - analyze question types and compliance print("\nlist_questions() - Analysis:") diff --git a/jupiterone/client.py b/jupiterone/client.py index 9ac677d..f5f025e 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -1246,12 +1246,44 @@ def fetch_downloaded_evaluation_results(self, download_url: str = None): return e - def list_questions(self): - """List all defined Questions configured in J1 Account Questions Library""" + def list_questions(self, search_query: str = None, tags: List[str] = None): + """List all defined Questions configured in J1 Account Questions Library + + Args: + search_query (str, optional): Search query to filter questions by title or description + tags (List[str], optional): List of tags to filter questions by + + Returns: + List[Dict]: List of question objects + + Example: + # List all questions + all_questions = j1_client.list_questions() + + # Search for security-related questions + security_questions = j1_client.list_questions(search_query="security") + + # Filter by specific tags + compliance_questions = j1_client.list_questions(tags=["compliance", "cis"]) + + # Combine search and tags + security_compliance = j1_client.list_questions( + search_query="encryption", + tags=["security", "compliance"] + ) + """ results = [] + # Build variables for the GraphQL query + variables = {} + if search_query: + variables["searchQuery"] = search_query + if tags: + variables["tags"] = tags + data = { "query": QUESTIONS, + "variables": variables, "flags": { "variableResultSize": True } @@ -1267,10 +1299,16 @@ def list_questions(self): cursor = r["data"]["questions"]["pageInfo"]["endCursor"] # cursor query until last page fetched + # Preserve existing variables and add cursor + cursor_variables = variables.copy() + cursor_variables["cursor"] = cursor + data = { "query": QUESTIONS, - "variables": {"cursor": cursor}, - "flags": {"variableResultSize": True}, + "variables": cursor_variables, + "flags": { + "variableResultSize": True + }, } r = requests.post( diff --git a/tests/test_list_questions.py b/tests/test_list_questions.py index 89554f3..0d0fb0a 100644 --- a/tests/test_list_questions.py +++ b/tests/test_list_questions.py @@ -4,6 +4,7 @@ from unittest.mock import patch, Mock from jupiterone.client import JupiterOneClient from jupiterone.constants import QUESTIONS +from typing import List class TestListQuestions: @@ -464,6 +465,355 @@ def test_list_questions_malformed_response(self, mock_post): # Missing fields should be None or not present assert 'title' not in question or question['title'] is None + @patch('jupiterone.client.requests.post') + def test_list_questions_with_search_query(self, mock_post): + """Test questions listing with search query parameter""" + # Mock response for search query + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Security Compliance Check", + "description": "Check for security compliance issues", + "tags": ["security", "compliance"], + "queries": [{"name": "SecurityQuery", "query": "FIND Finding WITH severity='HIGH'"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 1, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions with search query + result = self.client.list_questions(search_query="security") + + # Verify result + assert len(result) == 1 + assert result[0]['title'] == "Security Compliance Check" + assert "security" in result[0]['tags'] + + # Verify API call with search query + mock_post.assert_called_once() + call_args = mock_post.call_args + assert call_args[1]['json']['variables']['searchQuery'] == "security" + + @patch('jupiterone.client.requests.post') + def test_list_questions_with_tags_filter(self, mock_post): + """Test questions listing with tags filter parameter""" + # Mock response for tags filter + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "CIS AWS Compliance", + "description": "CIS AWS Foundations compliance checks", + "tags": ["cis", "aws", "compliance"], + "queries": [{"name": "CISQuery", "query": "FIND aws_instance WITH encrypted=false"}], + "compliance": { + "standard": "CIS AWS Foundations", + "requirements": ["2.1", "2.2"], + "controls": ["Data Protection"] + }, + "variables": [], + "accountId": "test-account", + "showTrend": True, + "pollingInterval": "ONE_HOUR", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 1, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions with tags filter + result = self.client.list_questions(tags=["cis", "aws"]) + + # Verify result + assert len(result) == 1 + assert result[0]['title'] == "CIS AWS Compliance" + assert "cis" in result[0]['tags'] + assert "aws" in result[0]['tags'] + + # Verify API call with tags filter + mock_post.assert_called_once() + call_args = mock_post.call_args + assert call_args[1]['json']['variables']['tags'] == ["cis", "aws"] + + @patch('jupiterone.client.requests.post') + def test_list_questions_with_search_and_tags(self, mock_post): + """Test questions listing with both search query and tags filter""" + # Mock response for combined search and tags + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Encryption Security Check", + "description": "Check for encryption compliance in security context", + "tags": ["security", "compliance", "encryption"], + "queries": [{"name": "EncryptionQuery", "query": "FIND DataStore WITH encrypted=false"}], + "compliance": { + "standard": "PCI-DSS", + "requirements": ["3.4"], + "controls": ["Data Protection"] + }, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 1, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions with both search query and tags + result = self.client.list_questions( + search_query="encryption", + tags=["security", "compliance"] + ) + + # Verify result + assert len(result) == 1 + assert result[0]['title'] == "Encryption Security Check" + assert "encryption" in result[0]['tags'] + assert "security" in result[0]['tags'] + assert "compliance" in result[0]['tags'] + + # Verify API call with both parameters + mock_post.assert_called_once() + call_args = mock_post.call_args + variables = call_args[1]['json']['variables'] + assert variables['searchQuery'] == "encryption" + assert variables['tags'] == ["security", "compliance"] + + @patch('jupiterone.client.requests.post') + def test_list_questions_with_pagination_and_filters(self, mock_post): + """Test questions listing with filters and pagination""" + # Mock first page response with filters + first_response = Mock() + first_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Security Question 1", + "tags": ["security"], + "queries": [{"name": "SecurityQuery1", "query": "FIND Host"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 2, + "pageInfo": { + "endCursor": "cursor-1", + "hasNextPage": True + } + } + } + } + + # Mock second page response with filters + second_response = Mock() + second_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-2", + "title": "Security Question 2", + "tags": ["security"], + "queries": [{"name": "SecurityQuery2", "query": "FIND User"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-02T00:00:00Z" + } + ], + "totalHits": 2, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + + # Set up mock to return different responses for each call + mock_post.side_effect = [first_response, second_response] + + # Call list_questions with filters + result = self.client.list_questions( + search_query="security", + tags=["security"] + ) + + # Verify result + assert len(result) == 2 + assert result[0]['title'] == "Security Question 1" + assert result[1]['title'] == "Security Question 2" + + # Verify API calls (2 calls for 2 pages) + assert mock_post.call_count == 2 + + # Check first call with filters + first_call = mock_post.call_args_list[0] + first_variables = first_call[1]['json']['variables'] + assert first_variables['searchQuery'] == "security" + assert first_variables['tags'] == ["security"] + + # Check second call with filters and cursor + second_call = mock_post.call_args_list[1] + second_variables = second_call[1]['json']['variables'] + assert second_variables['searchQuery'] == "security" + assert second_variables['tags'] == ["security"] + assert second_variables['cursor'] == "cursor-1" + + @patch('jupiterone.client.requests.post') + def test_list_questions_no_parameters(self, mock_post): + """Test questions listing with no parameters (default behavior)""" + # Mock response for no parameters + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [ + { + "id": "question-1", + "title": "Default Question", + "tags": ["default"], + "queries": [{"name": "DefaultQuery", "query": "FIND *"}], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY", + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z" + } + ], + "totalHits": 1, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions with no parameters + result = self.client.list_questions() + + # Verify result + assert len(result) == 1 + assert result[0]['title'] == "Default Question" + + # Verify API call with no variables + mock_post.assert_called_once() + call_args = mock_post.call_args + assert call_args[1]['json']['variables'] == {} + + @patch('jupiterone.client.requests.post') + def test_list_questions_empty_search_results(self, mock_post): + """Test questions listing with search that returns no results""" + # Mock empty response for search + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [], + "totalHits": 0, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions with search query + result = self.client.list_questions(search_query="nonexistent") + + # Verify result + assert len(result) == 0 + assert result == [] + + # Verify API call with search query + mock_post.assert_called_once() + call_args = mock_post.call_args + assert call_args[1]['json']['variables']['searchQuery'] == "nonexistent" + + @patch('jupiterone.client.requests.post') + def test_list_questions_empty_tags_results(self, mock_post): + """Test questions listing with tags filter that returns no results""" + # Mock empty response for tags filter + mock_response = Mock() + mock_response.json.return_value = { + "data": { + "questions": { + "questions": [], + "totalHits": 0, + "pageInfo": { + "endCursor": None, + "hasNextPage": False + } + } + } + } + mock_post.return_value = mock_response + + # Call list_questions with tags filter + result = self.client.list_questions(tags=["nonexistent_tag"]) + + # Verify result + assert len(result) == 0 + assert result == [] + + # Verify API call with tags filter + mock_post.assert_called_once() + call_args = mock_post.call_args + assert call_args[1]['json']['variables']['tags'] == ["nonexistent_tag"] + def test_list_questions_method_exists(self): """Test that list_questions method exists and is callable""" assert hasattr(self.client, 'list_questions') @@ -475,3 +825,41 @@ def test_list_questions_docstring(self): assert method.__doc__ is not None assert "List all defined Questions" in method.__doc__ assert "J1 account Questions Library" in method.__doc__ + + def test_list_questions_parameter_validation(self): + """Test that list_questions method accepts the correct parameter types""" + # Test that method exists with new signature + assert hasattr(self.client, 'list_questions') + method = getattr(self.client, 'list_questions') + + # Test that method can be called with different parameter combinations + import inspect + sig = inspect.signature(method) + params = list(sig.parameters.keys()) + + # Should have self, search_query, and tags parameters + assert 'search_query' in params + assert 'tags' in params + + # Check parameter types + search_query_param = sig.parameters['search_query'] + tags_param = sig.parameters['tags'] + + # search_query should be optional string + assert search_query_param.default is None + assert search_query_param.annotation == str or search_query_param.annotation == 'str' + + # tags should be optional List[str] + assert tags_param.default is None + assert tags_param.annotation == List[str] or 'List[str]' in str(tags_param.annotation) + + def test_list_questions_docstring_updated(self): + """Test that list_questions method documentation includes new parameters""" + method = getattr(self.client, 'list_questions') + docstring = method.__doc__ + + assert docstring is not None + assert "search_query" in docstring + assert "tags" in docstring + assert "searchQuery" in docstring or "search query" in docstring + assert "List[str]" in docstring or "List of tags" in docstring From 84fe38929f9b0b905fda2d8ebadf384ceab16604 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Thu, 14 Aug 2025 19:17:10 -0600 Subject: [PATCH 11/22] add get_question_details() method --- examples/08_questions_management.py | 88 ++++++++++ examples/README.md | 10 ++ examples/examples.py | 37 +++++ jupiterone/client.py | 28 ++++ jupiterone/constants.py | 42 +++++ tests/test_list_questions.py | 243 ++++++++++++++++++++++++++++ 6 files changed, 448 insertions(+) diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py index 64f359f..ff49b76 100644 --- a/examples/08_questions_management.py +++ b/examples/08_questions_management.py @@ -352,6 +352,91 @@ def list_questions_example(j1): except Exception as e: print(f"Error listing questions: {e}") +def get_question_details_example(j1): + """Demonstrate getting specific question details.""" + + print("=== Get Question Details Example ===\n") + + print("First, let's get a list of questions to find one to examine:") + try: + questions = j1.list_questions() + + if questions: + # Get details of the first question + first_question = questions[0] + question_id = first_question['id'] + question_title = first_question['title'] + + print(f"Getting detailed information for question: {question_title}") + print(f"Question ID: {question_id}") + + # Get full question details + question_details = j1.get_question_details(question_id=question_id) + + print(f"\nDetailed Question Information:") + print(f" Title: {question_details['title']}") + print(f" ID: {question_details['id']}") + print(f" Source ID: {question_details.get('sourceId', 'Not specified')}") + print(f" Description: {question_details.get('description', 'No description')}") + print(f" Tags: {', '.join(question_details.get('tags', []))}") + print(f" Last Updated: {question_details.get('lastUpdatedTimestamp', 'Not specified')}") + print(f" Account ID: {question_details.get('accountId', 'Not specified')}") + print(f" Show Trend: {question_details.get('showTrend', False)}") + print(f" Polling Interval: {question_details.get('pollingInterval', 'Not set')}") + + # Display queries + queries = question_details.get('queries', []) + print(f"\n Queries ({len(queries)}):") + for i, query in enumerate(queries): + print(f" Query {i+1}: {query.get('name', 'Unnamed')}") + print(f" - Query: {query.get('query', 'No query')}") + print(f" - Version: {query.get('version', 'Not specified')}") + print(f" - Results Are: {query.get('resultsAre', 'Not specified')}") + + # Display compliance information + compliance = question_details.get('compliance') + if compliance: + print(f"\n Compliance Information:") + if isinstance(compliance, dict): + print(f" Standard: {compliance.get('standard', 'Not specified')}") + requirements = compliance.get('requirements', []) + if requirements: + print(f" Requirements: {', '.join(map(str, requirements))}") + controls = compliance.get('controls', []) + if controls: + print(f" Controls: {', '.join(map(str, controls))}") + else: + print(f" Compliance data type: {type(compliance)}") + print(f" Compliance content: {compliance}") + else: + print(f"\n Compliance Information: None") + + # Display variables + variables = question_details.get('variables', []) + if variables: + print(f"\n Variables ({len(variables)}):") + for var in variables: + print(f" - Name: {var.get('name', 'Unnamed')}") + print(f" Required: {var.get('required', False)}") + print(f" Default: {var.get('default', 'None')}") + else: + print(f"\n Variables: None") + + # Display integration information + integration_def_id = question_details.get('integrationDefinitionId') + if integration_def_id: + print(f"\n Integration Definition ID: {integration_def_id}") + else: + print(f"\n Integration Definition ID: None") + + else: + print("No questions found in the account to examine") + + except Exception as e: + print(f"Error getting question details: {e}") + print(f"Error type: {type(e).__name__}") + print(f"Error details: {str(e)}") + def question_use_cases(j1): """Demonstrate real-world use cases for questions.""" @@ -430,6 +515,9 @@ def main(): time.sleep(1) list_questions_example(j1) + time.sleep(1) + + get_question_details_example(j1) question_use_cases(j1) diff --git a/examples/README.md b/examples/README.md index a50802d..fa13e6d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -128,6 +128,7 @@ This directory contains comprehensive examples demonstrating how to use the Jupi **Key Methods Demonstrated**: - `create_question()` - Create questions with J1QL queries - `list_questions()` - List all questions in the account +- `get_question_details()` - Get detailed information for a specific question by ID ### 9. **examples.py** **Purpose**: Comprehensive examples of all major SDK methods @@ -141,6 +142,7 @@ This directory contains comprehensive examples demonstrating how to use the Jupi **Key Methods Demonstrated**: - All major SDK methods including: - `list_questions()` - List and analyze all questions in the account +- `get_question_details()` - Get detailed information for specific questions - `create_question()` - Create questions with various configurations - Entity lifecycle management methods - Relationship management methods @@ -301,6 +303,14 @@ security_compliance = j1.list_questions( tags=["security", "compliance"] ) +# Get detailed information for a specific question +question_details = j1.get_question_details( + question_id="f90f9aa1-f9ff-47f7-ab34-ce8fa11c7add" +) +print(f"Question title: {question_details['title']}") +print(f"Compliance standard: {question_details.get('compliance', {}).get('standard')}") +print(f"Number of queries: {len(question_details.get('queries', []))}") + # Analyze by compliance standards compliance_standards = {} for question in questions: diff --git a/examples/examples.py b/examples/examples.py index 71c6ffa..7e31763 100644 --- a/examples/examples.py +++ b/examples/examples.py @@ -649,4 +649,41 @@ polling_questions = [q for q in list_questions_r if 'pollingInterval' in q and q['pollingInterval'] and q['pollingInterval'] != 'DISABLED'] print(f" Questions with polling enabled: {len(polling_questions)}") +# get_question_details - get specific question details +print("\nget_question_details() - Get specific question:") +if list_questions_r: + # Get details of the first question + first_question_id = list_questions_r[0]['id'] + try: + question_details = j1.get_question_details(question_id=first_question_id) + print(f" Retrieved details for question: {question_details['title']}") + print(f" Question ID: {question_details['id']}") + print(f" Description: {question_details.get('description', 'No description')}") + print(f" Tags: {question_details.get('tags', [])}") + print(f" Number of queries: {len(question_details.get('queries', []))}") + print(f" Show trend: {question_details.get('showTrend', False)}") + print(f" Polling interval: {question_details.get('pollingInterval', 'Not set')}") + + # Show compliance details if available + if question_details.get('compliance'): + compliance = question_details['compliance'] + if isinstance(compliance, dict): + print(f" Compliance standard: {compliance.get('standard', 'Not specified')}") + if 'requirements' in compliance: + reqs = compliance['requirements'] + if isinstance(reqs, list): + print(f" Compliance requirements: {', '.join(map(str, reqs))}") + + # Show variables if available + if question_details.get('variables'): + variables = question_details['variables'] + print(f" Variables: {len(variables)}") + for var in variables: + print(f" - {var.get('name', 'Unnamed')}: required={var.get('required', False)}, default={var.get('default', 'None')}") + + except Exception as e: + print(f" Error getting question details: {e}") +else: + print(" No questions available to get details for") + print() diff --git a/jupiterone/client.py b/jupiterone/client.py index f5f025e..95bd9f4 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -43,6 +43,7 @@ UPDATE_RULE_INSTANCE, EVALUATE_RULE_INSTANCE, QUESTIONS, + GET_QUESTION, CREATE_QUESTION, COMPLIANCE_FRAMEWORK_ITEM, LIST_COLLECTION_RESULTS, @@ -1318,6 +1319,33 @@ def list_questions(self, search_query: str = None, tags: List[str] = None): return results + def get_question_details(self, question_id: str = None): + """Get details of a specific question by ID + + Args: + question_id (str): The unique ID of the question to retrieve + + Returns: + Dict: The question object with all its details + + Example: + question_details = j1_client.get_question_details( + question_id="f90f9aa1-f9ff-47f7-ab34-ce8fa11c7add" + ) + + Raises: + ValueError: If question_id is not provided + JupiterOneApiError: If the question is not found or other API errors occur + """ + if not question_id: + raise ValueError("question_id is required") + + variables = {"id": question_id} + + response = self._execute_query(GET_QUESTION, variables=variables) + + return response["data"]["question"] + def create_question( self, title: str, diff --git a/jupiterone/constants.py b/jupiterone/constants.py index 537716b..6efd052 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -920,6 +920,48 @@ __typename } """ + +GET_QUESTION = """ + query question($id: ID!) { + question(id: $id) { + ...QuestionFields + __typename + } + } + + fragment QuestionFields on Question { + id + sourceId + title + description + tags + lastUpdatedTimestamp + queries { + name + query + version + resultsAre + __typename + } + compliance { + standard + requirements + controls + __typename + } + variables { + name + required + default + __typename + } + accountId + integrationDefinitionId + showTrend + pollingInterval + __typename + } +""" CREATE_QUESTION = """ mutation CreateQuestion($question: CreateQuestionInput!) { createQuestion(question: $question) { diff --git a/tests/test_list_questions.py b/tests/test_list_questions.py index 0d0fb0a..790e4e3 100644 --- a/tests/test_list_questions.py +++ b/tests/test_list_questions.py @@ -5,6 +5,7 @@ from jupiterone.client import JupiterOneClient from jupiterone.constants import QUESTIONS from typing import List +from jupiterone.errors import JupiterOneApiError class TestListQuestions: @@ -863,3 +864,245 @@ def test_list_questions_docstring_updated(self): assert "tags" in docstring assert "searchQuery" in docstring or "search query" in docstring assert "List[str]" in docstring or "List of tags" in docstring + + +class TestGetQuestionDetails: + """Test get_question_details method""" + + def setup_method(self): + """Set up test fixtures""" + self.client = JupiterOneClient(account="test-account", token="test-token") + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_question_details_success(self, mock_execute): + """Test successful retrieval of question details""" + # Mock response + mock_execute.return_value = { + "data": { + "question": { + "id": "question-123", + "sourceId": "source-123", + "title": "Test Question", + "description": "Test question description", + "tags": ["test", "security"], + "lastUpdatedTimestamp": "2024-01-01T00:00:00Z", + "queries": [ + { + "name": "TestQuery", + "query": "FIND Host WITH open=true", + "version": "v1", + "resultsAre": "BAD" + } + ], + "compliance": { + "standard": "CIS", + "requirements": ["2.1", "2.2"], + "controls": ["Network Security"] + }, + "variables": [ + { + "name": "environment", + "required": True, + "default": "production" + } + ], + "accountId": "test-account", + "integrationDefinitionId": "integration-123", + "showTrend": True, + "pollingInterval": "ONE_HOUR" + } + } + } + + # Call get_question_details + result = self.client.get_question_details(question_id="question-123") + + # Verify result + assert result['id'] == "question-123" + assert result['title'] == "Test Question" + assert result['description'] == "Test question description" + assert result['tags'] == ["test", "security"] + assert result['sourceId'] == "source-123" + assert result['accountId'] == "test-account" + assert result['showTrend'] is True + assert result['pollingInterval'] == "ONE_HOUR" + + # Verify queries + queries = result['queries'] + assert len(queries) == 1 + assert queries[0]['name'] == "TestQuery" + assert queries[0]['query'] == "FIND Host WITH open=true" + assert queries[0]['version'] == "v1" + assert queries[0]['resultsAre'] == "BAD" + + # Verify compliance + compliance = result['compliance'] + assert compliance['standard'] == "CIS" + assert compliance['requirements'] == ["2.1", "2.2"] + assert compliance['controls'] == ["Network Security"] + + # Verify variables + variables = result['variables'] + assert len(variables) == 1 + assert variables[0]['name'] == "environment" + assert variables[0]['required'] is True + assert variables[0]['default'] == "production" + + # Verify API call + mock_execute.assert_called_once() + call_args = mock_execute.call_args + assert call_args[1]['variables']['id'] == "question-123" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_question_details_minimal_response(self, mock_execute): + """Test question details with minimal response data""" + # Mock minimal response + mock_execute.return_value = { + "data": { + "question": { + "id": "question-456", + "title": "Minimal Question", + "tags": [], + "queries": [], + "compliance": None, + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "DISABLED" + } + } + } + + # Call get_question_details + result = self.client.get_question_details(question_id="question-456") + + # Verify result + assert result['id'] == "question-456" + assert result['title'] == "Minimal Question" + assert result['tags'] == [] + assert result['queries'] == [] + assert result['compliance'] is None + assert result['variables'] == [] + assert result['showTrend'] is False + assert result['pollingInterval'] == "DISABLED" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_question_details_with_compliance_list(self, mock_execute): + """Test question details when compliance is returned as a list""" + # Mock response with compliance as list (edge case) + mock_execute.return_value = { + "data": { + "question": { + "id": "question-789", + "title": "List Compliance Question", + "tags": ["compliance"], + "queries": [{"name": "Query1", "query": "FIND Host"}], + "compliance": [ + { + "standard": "CIS", + "requirements": ["1.1"], + "controls": ["Access Control"] + } + ], + "variables": [], + "accountId": "test-account", + "showTrend": False, + "pollingInterval": "ONE_DAY" + } + } + } + + # Call get_question_details + result = self.client.get_question_details(question_id="question-789") + + # Verify result + assert result['id'] == "question-789" + assert result['title'] == "List Compliance Question" + + # Verify compliance (should handle list gracefully) + compliance = result['compliance'] + assert isinstance(compliance, list) + assert len(compliance) == 1 + assert compliance[0]['standard'] == "CIS" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_question_details_not_found(self, mock_execute): + """Test question details when question is not found""" + # Mock response for question not found + mock_execute.return_value = { + "data": { + "question": None + } + } + + # Call get_question_details + result = self.client.get_question_details(question_id="nonexistent") + + # Verify result + assert result is None + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_question_details_api_error(self, mock_execute): + """Test question details with API error""" + # Mock API error response + mock_execute.side_effect = JupiterOneApiError("Question not found") + + # Call get_question_details and expect error + with pytest.raises(JupiterOneApiError): + self.client.get_question_details(question_id="invalid-id") + + def test_get_question_details_no_id(self): + """Test get_question_details without providing question_id""" + # Call get_question_details without ID + with pytest.raises(ValueError, match="question_id is required"): + self.client.get_question_details() + + def test_get_question_details_empty_id(self): + """Test get_question_details with empty question_id""" + # Call get_question_details with empty ID + with pytest.raises(ValueError, match="question_id is required"): + self.client.get_question_details(question_id="") + + def test_get_question_details_none_id(self): + """Test get_question_details with None question_id""" + # Call get_question_details with None ID + with pytest.raises(ValueError, match="question_id is required"): + self.client.get_question_details(question_id=None) + + def test_get_question_details_method_exists(self): + """Test that get_question_details method exists and is callable""" + assert hasattr(self.client, 'get_question_details') + assert callable(self.client.get_question_details) + + def test_get_question_details_docstring(self): + """Test that get_question_details method has proper documentation""" + method = getattr(self.client, 'get_question_details') + docstring = method.__doc__ + + assert docstring is not None + assert "Get details of a specific question by ID" in docstring + assert "question_id" in docstring + assert "Returns" in docstring + assert "Example" in docstring + assert "Raises" in docstring + + def test_get_question_details_parameter_validation(self): + """Test that get_question_details method accepts the correct parameter types""" + # Test that method exists with correct signature + assert hasattr(self.client, 'get_question_details') + method = getattr(self.client, 'get_question_details') + + # Test that method can be called with different parameter combinations + import inspect + sig = inspect.signature(method) + params = list(sig.parameters.keys()) + + # Should have self and question_id parameters + assert 'question_id' in params + + # Check parameter types + question_id_param = sig.parameters['question_id'] + + # question_id should be optional string + assert question_id_param.default is None + assert question_id_param.annotation == str or question_id_param.annotation == 'str' From 2b229be748a3caf58028cc4acfd66137c5096ad9 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 12:20:11 -0600 Subject: [PATCH 12/22] add get_cft_upload_url() and update client to use deleteEntityV2 --- examples/uploadData.py | 172 ++++++++++++++++++++++++++++++++++++++++ jupiterone/client.py | 58 +++++++++++++- jupiterone/constants.py | 20 ++--- 3 files changed, 234 insertions(+), 16 deletions(-) create mode 100644 examples/uploadData.py diff --git a/examples/uploadData.py b/examples/uploadData.py new file mode 100644 index 0000000..223a2d4 --- /dev/null +++ b/examples/uploadData.py @@ -0,0 +1,172 @@ +import json +from pathlib import Path +import requests +import os +import time +from colorama import init, Fore, Style +from prompt_toolkit import prompt +from prompt_toolkit.completion import WordCompleter + +# Initialize colorama +init(autoreset=True) + +# Load configuration + +script_dir = Path(__file__).parent.parent + +with open(f'{script_dir}/infra/integrations/integration_outputs.json', 'r') as f: + config = json.load(f) + +# JupiterOne API details +API_URL = 'https://api.us.jupiterone.io/graphql' +HEADERS = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {os.environ.get("JUPITERONE_API_KEY")}', + 'JupiterOne-Account': os.environ.get("JUPITERONE_ACCOUNT") +} + +def get_upload_url(integration_instance_id, filename, dataset_id): + query = """ + mutation integrationFileTransferUploadUrl( + $integrationInstanceId: String! + $filename: String! + $datasetId: String! + ) { + integrationFileTransferUploadUrl( + integrationInstanceId: $integrationInstanceId + filename: $filename + datasetId: $datasetId + ) { + uploadUrl + expiresIn + } + } + """ + variables = { + "integrationInstanceId": integration_instance_id, + "filename": filename, + "datasetId": dataset_id + } + response = requests.post(API_URL, json={"query": query, "variables": variables}, headers=HEADERS) + return response.json()['data']['integrationFileTransferUploadUrl']['uploadUrl'] + +def upload_file(upload_url, file_path): + with open(file_path, 'rb') as f: + response = requests.put(upload_url, data=f, headers={'Content-Type': 'text/csv'}) + return response.status_code + +def invoke_integration(integration_instance_id): + query = """ + mutation InvokeIntegrationInstance( + $id: String! + ) { + invokeIntegrationInstance( + id: $id + ) { + success + integrationJobId + } + } + """ + variables = {"id": integration_instance_id} + response = requests.post(API_URL, json={"query": query, "variables": variables}, headers=HEADERS) + response_json = response.json() + + if 'errors' in response_json: + error = response_json['errors'][0] + if error.get('extensions', {}).get('code') == 'ALREADY_EXECUTING_ERROR': + return 'ALREADY_RUNNING' + else: + print(f"GraphQL error: {error['message']}") + return False + elif 'data' in response_json and response_json['data'] is not None: + if 'invokeIntegrationInstance' in response_json['data']: + return response_json['data']['invokeIntegrationInstance']['success'] + else: + print(f"Unexpected response format: 'invokeIntegrationInstance' not found in data") + return False + else: + print(f"Unexpected response format: {response_json}") + return False + +def print_colored(message, color=Fore.WHITE, style=Style.NORMAL): + print(f"{style}{color}{message}") + +def select_integrations(): + integration_names = list(config.keys()) + integration_names_lower = [name.lower() for name in integration_names] + completer = WordCompleter(integration_names + ['all']) + + print_colored("Available integrations:", Fore.CYAN, Style.BRIGHT) + for name in integration_names: + print_colored(f" - {name}", Fore.CYAN) + + while True: + selection = prompt( + "Enter integration names to run (comma-separated) or 'all': ", + completer=completer + ).strip().lower() + + if selection == 'all': + return integration_names + + selected = [name.strip() for name in selection.split(',')] + valid_selections = [] + invalid = [] + + for name in selected: + if name in integration_names_lower: + valid_selections.append(integration_names[integration_names_lower.index(name)]) + else: + invalid.append(name) + + if invalid: + print_colored(f"Invalid integrations: {', '.join(invalid)}. Please try again.", Fore.RED) + else: + return valid_selections + +def main(): + # Check if environment variables are set + if not os.environ.get("JUPITERONE_API_KEY") or not os.environ.get("JUPITERONE_ACCOUNT"): + print_colored("Error: JUPITERONE_API_KEY and JUPITERONE_ACCOUNT environment variables must be set.", Fore.RED, Style.BRIGHT) + return + + selected_integrations = select_integrations() + + for integration_name in selected_integrations: + integration_data = config[integration_name] + integration_instance_id = integration_data['integrationInstanceId'] + source_files = integration_data['sourceFiles'].split(',') + dataset_ids = integration_data['dataSetIds'].split(',') + + print_colored(f"\nProcessing integration: {integration_name}", Fore.GREEN, Style.BRIGHT) + + for filename, dataset_id in zip(source_files, dataset_ids): + print_colored(f" Uploading {filename} for dataset {dataset_id}", Fore.YELLOW) + upload_url = get_upload_url(integration_instance_id, filename, dataset_id) + + file_path = os.path.join(script_dir, 'data', filename) + + status_code = upload_file(upload_url, file_path) + if status_code == 200: + print_colored(f" āœ” Successfully uploaded {filename}", Fore.GREEN) + else: + print_colored(f" ✘ Failed to upload {filename}. Status code: {status_code}", Fore.RED) + + print_colored(f" Invoking integration: {integration_name}", Fore.YELLOW) + try: + result = invoke_integration(integration_instance_id) + if result == True: + print_colored(f" āœ” Successfully invoked integration: {integration_name}", Fore.GREEN) + elif result == 'ALREADY_RUNNING': + print_colored(f" ⚠ Integration {integration_name} is already running. Skipping.", Fore.YELLOW) + else: + print_colored(f" ✘ Failed to invoke integration: {integration_name}", Fore.RED) + except Exception as e: + print_colored(f" ✘ Error invoking integration {integration_name}: {str(e)}", Fore.RED) + + print() # Empty line for readability + time.sleep(5) # Wait for 5 seconds before the next integration + +if __name__ == "__main__": + main() diff --git a/jupiterone/client.py b/jupiterone/client.py index 95bd9f4..20c0d23 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -490,15 +490,19 @@ def create_entity(self, **kwargs) -> Dict: response = self._execute_query(query=CREATE_ENTITY, variables=variables) return response["data"]["createEntity"] - def delete_entity(self, entity_id: str = None) -> Dict: - """Deletes an entity from the graph. Note this is a hard delete. + def delete_entity(self, entity_id: str = None, timestamp: int = None, hard_delete: bool = True) -> Dict: + """Deletes an entity from the graph. args: entity_id (str): Entity ID for entity to delete + timestamp (int, optional): Timestamp for the deletion. Defaults to None. + hard_delete (bool): Whether to perform a hard delete. Defaults to True. """ - variables = {"entityId": entity_id} + variables = {"entityId": entity_id, "hardDelete": hard_delete} + if timestamp: + variables["timestamp"] = timestamp response = self._execute_query(DELETE_ENTITY, variables=variables) - return response["data"]["deleteEntity"] + return response["data"]["deleteEntityV2"] def update_entity(self, entity_id: str = None, properties: Dict = None) -> Dict: """ @@ -1517,3 +1521,49 @@ def update_entity_v2(self, entity_id: str = None, properties: Dict = None) -> Di response = self._execute_query(UPDATE_ENTITYV2, variables=variables) return response["data"]["updateEntityV2"] + + def get_cft_upload_url(self, integration_instance_id: str, filename: str, dataset_id: str) -> Dict: + """ + Get an upload URL for Custom File Transfer integration. + + args: + integration_instance_id (str): The integration instance ID + filename (str): The filename to upload + dataset_id (str): The dataset ID for the upload + + Returns: + Dict: Response containing uploadUrl and expiresIn + + Example: + upload_info = j1_client.get_cft_upload_url( + integration_instance_id="123e4567-e89b-12d3-a456-426614174000", + filename="data.csv", + dataset_id="dataset-123" + ) + upload_url = upload_info['uploadUrl'] + """ + query = """ + mutation integrationFileTransferUploadUrl( + $integrationInstanceId: String! + $filename: String! + $datasetId: String! + ) { + integrationFileTransferUploadUrl( + integrationInstanceId: $integrationInstanceId + filename: $filename + datasetId: $datasetId + ) { + uploadUrl + expiresIn + } + } + """ + + variables = { + "integrationInstanceId": integration_instance_id, + "filename": filename, + "datasetId": dataset_id + } + + response = self._execute_query(query, variables) + return response["data"]["integrationFileTransferUploadUrl"] diff --git a/jupiterone/constants.py b/jupiterone/constants.py index 6efd052..4b3c05b 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -28,18 +28,14 @@ } """ DELETE_ENTITY = """ - mutation DeleteEntity($entityId: String!, $timestamp: Long) { - deleteEntity(entityId: $entityId, timestamp: $timestamp) { - entity { - _id - } - vertex { - id - entity { - _id - } - properties - } + mutation DeleteEntity($entityId: String!, $timestamp: Long, $hardDelete: Boolean) { + deleteEntityV2( + entityId: $entityId + timestamp: $timestamp + hardDelete: $hardDelete + ) { + entity + __typename } } """ From 014d97b49aca68f77d7d3f45b841710719b93e28 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 13:08:37 -0600 Subject: [PATCH 13/22] add new CFT methods and create usage example script --- examples/09_custom_file_transfer_example.py | 313 ++++++++++++++++++++ examples/uploadData.py | 172 ----------- jupiterone/client.py | 131 ++++++++ jupiterone/constants.py | 13 + 4 files changed, 457 insertions(+), 172 deletions(-) create mode 100644 examples/09_custom_file_transfer_example.py delete mode 100644 examples/uploadData.py diff --git a/examples/09_custom_file_transfer_example.py b/examples/09_custom_file_transfer_example.py new file mode 100644 index 0000000..f600560 --- /dev/null +++ b/examples/09_custom_file_transfer_example.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +Custom File Transfer (CFT) Integration Example + +This script demonstrates how to use the JupiterOne Python client to: +1. Get an upload URL for Custom File Transfer integration +2. Upload a CSV file to the integration +3. Invoke the integration to process the uploaded file + +Prerequisites: +- JupiterOne account with API access +- Custom File Transfer integration instance configured +- CSV file to upload + +Usage: + python 09_custom_file_transfer_example.py +""" + +import os +import sys +import time +from pathlib import Path + +# Add the parent directory to the path so we can import the jupiterone client +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from jupiterone.client import JupiterOneClient + + +def setup_client(): + """Set up the JupiterOne client with credentials.""" + # You can set these as environment variables or replace with your actual values + account = os.getenv('JUPITERONE_ACCOUNT') + token = os.getenv('JUPITERONE_API_TOKEN') + + if not account or not token: + print("Error: Please set JUPITERONE_ACCOUNT and JUPITERONE_API_TOKEN environment variables") + print("Example:") + print("export JUPITERONE_ACCOUNT='your-account-id'") + print("export JUPITERONE_API_TOKEN='your-api-token'") + sys.exit(1) + + try: + client = JupiterOneClient(account=account, token=token) + print(f"āœ… Successfully connected to JupiterOne account: {account}") + return client + except Exception as e: + print(f"āŒ Failed to connect to JupiterOne: {e}") + sys.exit(1) + + +def get_cft_upload_url_example(client, integration_instance_id, filename, dataset_id): + """ + Example of getting an upload URL for Custom File Transfer integration. + + Args: + client: JupiterOne client instance + integration_instance_id: ID of the CFT integration instance + filename: Name of the file to upload + dataset_id: Dataset ID for the upload + """ + print("\n" + "="*60) + print("1. GETTING CFT UPLOAD URL") + print("="*60) + + try: + print(f"Requesting upload URL for:") + print(f" - Integration Instance ID: {integration_instance_id}") + print(f" - Filename: {filename}") + print(f" - Dataset ID: {dataset_id}") + + upload_info = client.get_cft_upload_url( + integration_instance_id=integration_instance_id, + filename=filename, + dataset_id=dataset_id + ) + + print("āœ… Upload URL obtained successfully!") + print(f" - Upload URL: {upload_info['uploadUrl']}") + print(f" - Expires In: {upload_info['expiresIn']} seconds") + + return upload_info + + except Exception as e: + print(f"āŒ Failed to get upload URL: {e}") + return None + + +def upload_cft_file_example(client, upload_url, file_path): + """ + Example of uploading a CSV file to Custom File Transfer integration. + + Args: + client: JupiterOne client instance + upload_url: Upload URL obtained from get_cft_upload_url() + file_path: Local path to the CSV file + """ + print("\n" + "="*60) + print("2. UPLOADING CSV FILE") + print("="*60) + + try: + # Check if file exists + if not os.path.exists(file_path): + print(f"āŒ File not found: {file_path}") + return None + + # Check if file is CSV + if not file_path.lower().endswith('.csv'): + print(f"āŒ File must be a CSV file. Got: {file_path}") + return None + + print(f"Uploading file:") + print(f" - File path: {file_path}") + print(f" - File size: {os.path.getsize(file_path)} bytes") + print(f" - Upload URL: {upload_url}") + + # Upload the file + result = client.upload_cft_file( + upload_url=upload_url, + file_path=file_path + ) + + print("āœ… File upload completed!") + print(f" - Status code: {result['status_code']}") + print(f" - Success: {result['success']}") + print(f" - Response headers: {result['headers']}") + + if result['success']: + print(" - File uploaded successfully to JupiterOne") + else: + print(f" - Upload failed. Response data: {result['response_data']}") + + return result + + except Exception as e: + print(f"āŒ Failed to upload file: {e}") + return None + + +def invoke_cft_integration_example(client, integration_instance_id): + """ + Example of invoking a Custom File Transfer integration instance. + + Args: + client: JupiterOne client instance + integration_instance_id: ID of the CFT integration instance + """ + print("\n" + "="*60) + print("3. INVOKING CFT INTEGRATION") + print("="*60) + + try: + print(f"Invoicing integration instance:") + print(f" - Integration Instance ID: {integration_instance_id}") + + # Invoke the integration + result = client.invoke_cft_integration( + integration_instance_id=integration_instance_id + ) + + if result == True: + print("āœ… Integration invoked successfully!") + print(" - The integration is now processing the uploaded file") + elif result == 'ALREADY_RUNNING': + print("āš ļø Integration is already running") + print(" - The integration instance is currently executing") + else: + print("āŒ Integration invocation failed") + print(" - Check the integration instance configuration") + + return result + + except Exception as e: + print(f"āŒ Failed to invoke integration: {e}") + return None + + +def complete_workflow_example(client, integration_instance_id, file_path, dataset_id): + """ + Complete workflow example combining all three methods. + + Args: + client: JupiterOne client instance + integration_instance_id: ID of the CFT integration instance + file_path: Local path to the CSV file + dataset_id: Dataset ID for the upload + """ + print("\n" + "="*60) + print("COMPLETE CFT WORKFLOW") + print("="*60) + + print("Starting complete Custom File Transfer workflow...") + + # Step 1: Get upload URL + upload_info = get_cft_upload_url_example( + client, integration_instance_id, os.path.basename(file_path), dataset_id + ) + + if not upload_info: + print("āŒ Workflow failed at step 1: Getting upload URL") + return False + + # Step 2: Upload file + upload_result = upload_cft_file_example(client, upload_info['uploadUrl'], file_path) + + if not upload_result or not upload_result['success']: + print("āŒ Workflow failed at step 2: Uploading file") + return False + + # Step 3: Invoke integration + invoke_result = invoke_cft_integration_example(client, integration_instance_id) + + if invoke_result == True: + print("\nšŸŽ‰ Complete workflow successful!") + print(" - File uploaded successfully") + print(" - Integration invoked successfully") + print(" - Data processing has begun") + return True + elif invoke_result == 'ALREADY_RUNNING': + print("\nāš ļø Workflow partially successful") + print(" - File uploaded successfully") + print(" - Integration is already running") + return True + else: + print("\nāŒ Workflow failed at step 3: Invoking integration") + return False + + +def main(): + """Main function demonstrating the CFT methods.""" + print("šŸš€ JupiterOne Custom File Transfer Integration Examples") + print("="*60) + + # Configuration - Replace these with your actual values + INTEGRATION_INSTANCE_ID = os.getenv('J1_CFT_INSTANCE_ID', 'your-integration-instance-id') + DATASET_ID = os.getenv('J1_CFT_DATASET_ID', 'your-dataset-id') + CSV_FILE_PATH = os.getenv('J1_CSV_FILE_PATH', 'examples/scanned_hosts.csv') + + # Check if we have the required configuration + if INTEGRATION_INSTANCE_ID == 'your-integration-instance-id': + print("āš ļø Configuration required:") + print("Set the following environment variables:") + print(" - J1_CFT_INSTANCE_ID: Your CFT integration instance ID") + print(" - J1_CFT_DATASET_ID: Your dataset ID") + print(" - J1_CSV_FILE_PATH: Path to your CSV file (optional, defaults to examples/scanned_hosts.csv)") + print("\nExample:") + print("export J1_CFT_INSTANCE_ID='123e4567-e89b-12d3-a456-426614174000'") + print("export J1_CFT_DATASET_ID='dataset-123'") + print("export J1_CSV_FILE_PATH='/path/to/your/file.csv'") + print("\nOr update the variables in this script directly.") + return + + # Set up the client + client = setup_client() + + # Individual method examples + print("\nšŸ“š INDIVIDUAL METHOD EXAMPLES") + + # Example 1: Get upload URL + upload_info = get_cft_upload_url_example( + client, INTEGRATION_INSTANCE_ID, os.path.basename(CSV_FILE_PATH), DATASET_ID + ) + + if upload_info: + # Example 2: Upload file + upload_result = upload_cft_file_example( + client, upload_info['uploadUrl'], CSV_FILE_PATH + ) + + if upload_result and upload_result['success']: + # Example 3: Invoke integration + invoke_cft_integration_example(client, INTEGRATION_INSTANCE_ID) + + # Complete workflow example + print("\nšŸ”„ COMPLETE WORKFLOW EXAMPLE") + complete_workflow_example(client, INTEGRATION_INSTANCE_ID, CSV_FILE_PATH, DATASET_ID) + + print("\n" + "="*60) + print("šŸ“– USAGE PATTERNS") + print("="*60) + + print(""" +Common usage patterns: + +1. Single file upload and processing: + upload_info = client.get_cft_upload_url(instance_id, filename, dataset_id) + upload_result = client.upload_cft_file(upload_info['uploadUrl'], file_path) + if upload_result['success']: + client.invoke_cft_integration(instance_id) + +2. Batch processing multiple files: + for file_path in csv_files: + upload_info = client.get_cft_upload_url(instance_id, filename, dataset_id) + client.upload_cft_file(upload_info['uploadUrl'], file_path) + + # Invoke integration once after all files are uploaded + client.invoke_cft_integration(instance_id) + +3. Error handling and retries: + try: + result = client.upload_cft_file(upload_url, file_path) + if result['success']: + print("Upload successful") + else: + print(f"Upload failed: {result['response_data']}") + except Exception as e: + print(f"Upload error: {e}") + """) + + +if __name__ == "__main__": + main() diff --git a/examples/uploadData.py b/examples/uploadData.py deleted file mode 100644 index 223a2d4..0000000 --- a/examples/uploadData.py +++ /dev/null @@ -1,172 +0,0 @@ -import json -from pathlib import Path -import requests -import os -import time -from colorama import init, Fore, Style -from prompt_toolkit import prompt -from prompt_toolkit.completion import WordCompleter - -# Initialize colorama -init(autoreset=True) - -# Load configuration - -script_dir = Path(__file__).parent.parent - -with open(f'{script_dir}/infra/integrations/integration_outputs.json', 'r') as f: - config = json.load(f) - -# JupiterOne API details -API_URL = 'https://api.us.jupiterone.io/graphql' -HEADERS = { - 'Content-Type': 'application/json', - 'Authorization': f'Bearer {os.environ.get("JUPITERONE_API_KEY")}', - 'JupiterOne-Account': os.environ.get("JUPITERONE_ACCOUNT") -} - -def get_upload_url(integration_instance_id, filename, dataset_id): - query = """ - mutation integrationFileTransferUploadUrl( - $integrationInstanceId: String! - $filename: String! - $datasetId: String! - ) { - integrationFileTransferUploadUrl( - integrationInstanceId: $integrationInstanceId - filename: $filename - datasetId: $datasetId - ) { - uploadUrl - expiresIn - } - } - """ - variables = { - "integrationInstanceId": integration_instance_id, - "filename": filename, - "datasetId": dataset_id - } - response = requests.post(API_URL, json={"query": query, "variables": variables}, headers=HEADERS) - return response.json()['data']['integrationFileTransferUploadUrl']['uploadUrl'] - -def upload_file(upload_url, file_path): - with open(file_path, 'rb') as f: - response = requests.put(upload_url, data=f, headers={'Content-Type': 'text/csv'}) - return response.status_code - -def invoke_integration(integration_instance_id): - query = """ - mutation InvokeIntegrationInstance( - $id: String! - ) { - invokeIntegrationInstance( - id: $id - ) { - success - integrationJobId - } - } - """ - variables = {"id": integration_instance_id} - response = requests.post(API_URL, json={"query": query, "variables": variables}, headers=HEADERS) - response_json = response.json() - - if 'errors' in response_json: - error = response_json['errors'][0] - if error.get('extensions', {}).get('code') == 'ALREADY_EXECUTING_ERROR': - return 'ALREADY_RUNNING' - else: - print(f"GraphQL error: {error['message']}") - return False - elif 'data' in response_json and response_json['data'] is not None: - if 'invokeIntegrationInstance' in response_json['data']: - return response_json['data']['invokeIntegrationInstance']['success'] - else: - print(f"Unexpected response format: 'invokeIntegrationInstance' not found in data") - return False - else: - print(f"Unexpected response format: {response_json}") - return False - -def print_colored(message, color=Fore.WHITE, style=Style.NORMAL): - print(f"{style}{color}{message}") - -def select_integrations(): - integration_names = list(config.keys()) - integration_names_lower = [name.lower() for name in integration_names] - completer = WordCompleter(integration_names + ['all']) - - print_colored("Available integrations:", Fore.CYAN, Style.BRIGHT) - for name in integration_names: - print_colored(f" - {name}", Fore.CYAN) - - while True: - selection = prompt( - "Enter integration names to run (comma-separated) or 'all': ", - completer=completer - ).strip().lower() - - if selection == 'all': - return integration_names - - selected = [name.strip() for name in selection.split(',')] - valid_selections = [] - invalid = [] - - for name in selected: - if name in integration_names_lower: - valid_selections.append(integration_names[integration_names_lower.index(name)]) - else: - invalid.append(name) - - if invalid: - print_colored(f"Invalid integrations: {', '.join(invalid)}. Please try again.", Fore.RED) - else: - return valid_selections - -def main(): - # Check if environment variables are set - if not os.environ.get("JUPITERONE_API_KEY") or not os.environ.get("JUPITERONE_ACCOUNT"): - print_colored("Error: JUPITERONE_API_KEY and JUPITERONE_ACCOUNT environment variables must be set.", Fore.RED, Style.BRIGHT) - return - - selected_integrations = select_integrations() - - for integration_name in selected_integrations: - integration_data = config[integration_name] - integration_instance_id = integration_data['integrationInstanceId'] - source_files = integration_data['sourceFiles'].split(',') - dataset_ids = integration_data['dataSetIds'].split(',') - - print_colored(f"\nProcessing integration: {integration_name}", Fore.GREEN, Style.BRIGHT) - - for filename, dataset_id in zip(source_files, dataset_ids): - print_colored(f" Uploading {filename} for dataset {dataset_id}", Fore.YELLOW) - upload_url = get_upload_url(integration_instance_id, filename, dataset_id) - - file_path = os.path.join(script_dir, 'data', filename) - - status_code = upload_file(upload_url, file_path) - if status_code == 200: - print_colored(f" āœ” Successfully uploaded {filename}", Fore.GREEN) - else: - print_colored(f" ✘ Failed to upload {filename}. Status code: {status_code}", Fore.RED) - - print_colored(f" Invoking integration: {integration_name}", Fore.YELLOW) - try: - result = invoke_integration(integration_instance_id) - if result == True: - print_colored(f" āœ” Successfully invoked integration: {integration_name}", Fore.GREEN) - elif result == 'ALREADY_RUNNING': - print_colored(f" ⚠ Integration {integration_name} is already running. Skipping.", Fore.YELLOW) - else: - print_colored(f" ✘ Failed to invoke integration: {integration_name}", Fore.RED) - except Exception as e: - print_colored(f" ✘ Error invoking integration {integration_name}: {str(e)}", Fore.RED) - - print() # Empty line for readability - time.sleep(5) # Wait for 5 seconds before the next integration - -if __name__ == "__main__": - main() diff --git a/jupiterone/client.py b/jupiterone/client.py index 20c0d23..fa2306e 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -1,5 +1,6 @@ """ Python SDK for JupiterOne GraphQL API """ import json +import os from warnings import warn from typing import Dict, List, Union, Optional from datetime import datetime @@ -56,6 +57,7 @@ PARAMETER_LIST, UPSERT_PARAMETER, UPDATE_ENTITYV2, + INVOKE_INTEGRATION_INSTANCE, ) class JupiterOneClient: @@ -1567,3 +1569,132 @@ def get_cft_upload_url(self, integration_instance_id: str, filename: str, datase response = self._execute_query(query, variables) return response["data"]["integrationFileTransferUploadUrl"] + + def upload_cft_file(self, upload_url: str, file_path: str) -> Dict: + """ + Upload a CSV file to the Custom File Transfer integration using the provided upload URL. + + args: + upload_url (str): The upload URL obtained from get_cft_upload_url() + file_path (str): Local path to the CSV file to upload + + Returns: + Dict: Dictionary containing the full response data and status code: + - status_code (int): HTTP status code of the upload response + - response_data (dict): Full response data from the upload request + - success (bool): Whether the upload was successful (status code 200-299) + - headers (dict): Response headers from the upload request + + Raises: + FileNotFoundError: If the file doesn't exist + ValueError: If the file is not a CSV file + + Example: + # First get the upload URL + upload_info = j1_client.get_cft_upload_url( + integration_instance_id="123e4567-e89b-12d3-a456-426614174000", + filename="data.csv", + dataset_id="dataset-123" + ) + + # Then upload the CSV file + result = j1_client.upload_cft_file( + upload_url=upload_info['uploadUrl'], + file_path="/path/to/local/data.csv" + ) + + if result['success']: + print("CSV file uploaded successfully!") + print(f"Status code: {result['status_code']}") + print(f"Response headers: {result['headers']}") + else: + print(f"Upload failed with status code: {result['status_code']}") + print(f"Response data: {result['response_data']}") + """ + # Verify file exists + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + # Verify file is a CSV file + if not file_path.lower().endswith('.csv'): + raise ValueError(f"File must be a CSV file. Got: {file_path}") + + # Upload the CSV file with fixed content type + with open(file_path, 'rb') as f: + response = self.session.put( + upload_url, + data=f, + headers={'Content-Type': 'text/csv'}, + timeout=300 # 5 minute timeout for file uploads + ) + + # Prepare response data + response_data = {} + try: + # Try to parse JSON response if available + if response.headers.get('content-type', '').startswith('application/json'): + response_data = response.json() + else: + response_data = {'text': response.text} + except (ValueError, json.JSONDecodeError): + # If JSON parsing fails, use text content + response_data = {'text': response.text} + + # Return comprehensive response information + return { + 'status_code': response.status_code, + 'response_data': response_data, + 'success': 200 <= response.status_code < 300, + 'headers': dict(response.headers) + } + + def invoke_cft_integration(self, integration_instance_id: str) -> Union[bool, str]: + """ + Invoke a Custom File Transfer integration instance to process uploaded files. + + args: + integration_instance_id (str): The ID of the integration instance to invoke + + Returns: + Union[bool, str]: + - True: Integration was successfully invoked + - False: Integration invocation failed + - 'ALREADY_RUNNING': Integration is already executing + + Example: + # Invoke the CFT integration to process uploaded files + result = j1_client.invoke_cft_integration( + integration_instance_id="123e4567-e89b-12d3-a456-426614174000" + ) + + if result == True: + print("Integration invoked successfully!") + elif result == 'ALREADY_RUNNING': + print("Integration is already running") + else: + print("Integration invocation failed") + """ + variables = {"id": integration_instance_id} + + try: + response = self._execute_query(INVOKE_INTEGRATION_INSTANCE, variables) + + if 'data' in response and response['data'] is not None: + if 'invokeIntegrationInstance' in response['data']: + return response['data']['invokeIntegrationInstance']['success'] + else: + print(f"Unexpected response format: 'invokeIntegrationInstance' not found in data") + return False + else: + print(f"Unexpected response format: {response}") + return False + + except JupiterOneApiError as e: + # Check if it's an "already executing" error + if hasattr(e, 'errors') and e.errors: + for error in e.errors: + if error.get('extensions', {}).get('code') == 'ALREADY_EXECUTING_ERROR': + return 'ALREADY_RUNNING' + + # Re-raise the error if it's not an "already executing" error + raise diff --git a/jupiterone/constants.py b/jupiterone/constants.py index 4b3c05b..6d606ac 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -1313,3 +1313,16 @@ } } """ + +INVOKE_INTEGRATION_INSTANCE = """ + mutation InvokeIntegrationInstance( + $id: String! + ) { + invokeIntegrationInstance( + id: $id + ) { + success + integrationJobId + } + } +""" From 35f2056f7611becb830adfcabd5828bf7eb640d3 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 13:11:55 -0600 Subject: [PATCH 14/22] remove unused imports --- examples/09_custom_file_transfer_example.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/09_custom_file_transfer_example.py b/examples/09_custom_file_transfer_example.py index f600560..67c75cd 100644 --- a/examples/09_custom_file_transfer_example.py +++ b/examples/09_custom_file_transfer_example.py @@ -18,8 +18,6 @@ import os import sys -import time -from pathlib import Path # Add the parent directory to the path so we can import the jupiterone client sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) From c913715962887eb68b37441b3f05564574337eda Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 13:38:02 -0600 Subject: [PATCH 15/22] add update_question() --- examples/08_questions_management.py | 231 ++++++++++++++++++++++++++++ jupiterone/client.py | 95 ++++++++++++ jupiterone/constants.py | 44 ++++++ 3 files changed, 370 insertions(+) diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py index ff49b76..c127d7a 100644 --- a/examples/08_questions_management.py +++ b/examples/08_questions_management.py @@ -437,6 +437,197 @@ def get_question_details_example(j1): print(f"Error type: {type(e).__name__}") print(f"Error details: {str(e)}") +def update_question_examples(j1): + """Demonstrate updating existing questions.""" + + print("=== Update Question Examples ===\n") + + # First, let's get a list of questions to find one to update + print("1. Finding a question to update:") + try: + questions = j1.list_questions() + + if questions: + # Get the first question for demonstration + question_to_update = questions[0] + question_id = question_to_update['id'] + question_title = question_to_update['title'] + + print(f" Found question: {question_title}") + print(f" Question ID: {question_id}") + print(f" Current tags: {', '.join(question_to_update.get('tags', []))}") + print(f" Current description: {question_to_update.get('description', 'No description')[:50]}...") + print() + + # Example 1: Update title and description + print("2. Updating question title and description:") + try: + updated_question = j1.update_question( + question_id=question_id, + title=f"{question_title} - UPDATED", + description="This question has been updated with new information and improved clarity." + ) + + print(f" āœ… Successfully updated question!") + print(f" New title: {updated_question['title']}") + print(f" New description: {updated_question['description']}") + print() + + except Exception as e: + print(f" āŒ Error updating title/description: {e}\n") + + # Example 2: Update tags + print("3. Updating question tags:") + try: + current_tags = question_to_update.get('tags', []) + new_tags = current_tags + ["updated", "maintained", "reviewed"] + + updated_question = j1.update_question( + question_id=question_id, + tags=new_tags + ) + + print(f" āœ… Successfully updated tags!") + print(f" New tags: {', '.join(updated_question['tags'])}") + print() + + except Exception as e: + print(f" āŒ Error updating tags: {e}\n") + + # Example 3: Update queries + print("4. Updating question queries:") + try: + current_queries = question_to_update.get('queries', []) + if current_queries: + # Update the first query with improved version + updated_queries = current_queries.copy() + if len(updated_queries) > 0: + updated_queries[0] = { + **updated_queries[0], + "query": f"{updated_queries[0].get('query', '')} LIMIT 100", + "resultsAre": "INFORMATIVE" + } + + updated_question = j1.update_question( + question_id=question_id, + queries=updated_queries + ) + + print(f" āœ… Successfully updated queries!") + print(f" Number of queries: {len(updated_question['queries'])}") + print(f" First query updated: {updated_question['queries'][0]['query'][:50]}...") + print() + else: + print(" āš ļø No queries found to update") + print() + + except Exception as e: + print(f" āŒ Error updating queries: {e}\n") + + # Example 4: Comprehensive update + print("5. Comprehensive question update:") + try: + comprehensive_update = j1.update_question( + question_id=question_id, + title="Comprehensive Security Audit Question - UPDATED", + description="This question has been comprehensively updated to include multiple security checks and improved query performance.", + tags=["security", "audit", "comprehensive", "updated", "maintained"], + showTrend=True, + pollingInterval="ONE_DAY" + ) + + print(f" āœ… Successfully completed comprehensive update!") + print(f" Final title: {comprehensive_update['title']}") + print(f" Final tags: {', '.join(comprehensive_update['tags'])}") + print(f" Show trend: {comprehensive_update.get('showTrend', False)}") + print(f" Polling interval: {comprehensive_update.get('pollingInterval', 'Not set')}") + print() + + except Exception as e: + print(f" āŒ Error in comprehensive update: {e}\n") + + # Example 5: Update specific fields only + print("6. Updating specific fields only:") + try: + # Only update the description, leave everything else unchanged + specific_update = j1.update_question( + question_id=question_id, + description="This question focuses on specific security controls and compliance requirements." + ) + + print(f" āœ… Successfully updated description only!") + print(f" Description updated: {specific_update['description'][:50]}...") + print(f" Title remains: {specific_update['title']}") + print(f" Tags remain: {', '.join(specific_update['tags'])}") + print() + + except Exception as e: + print(f" āŒ Error updating specific fields: {e}\n") + + else: + print(" āš ļø No questions found in the account to update") + print() + + except Exception as e: + print(f" āŒ Error finding questions to update: {e}\n") + + # Example 7: Update with compliance metadata + print("7. Updating question with compliance metadata:") + try: + if questions: + compliance_update = j1.update_question( + question_id=question_id, + compliance={ + "standard": "CIS Controls", + "requirements": ["6.1", "6.2"], + "controls": ["Data Protection", "Access Control"] + } + ) + + print(f" āœ… Successfully updated compliance metadata!") + if 'compliance' in compliance_update: + compliance_data = compliance_update['compliance'] + if isinstance(compliance_data, dict): + print(f" Standard: {compliance_data.get('standard', 'Not specified')}") + print(f" Requirements: {', '.join(map(str, compliance_data.get('requirements', [])))}") + print(f" Controls: {', '.join(map(str, compliance_data.get('controls', [])))}") + else: + print(f" Compliance data type: {type(compliance_data)}") + print() + + except Exception as e: + print(f" āŒ Error updating compliance metadata: {e}\n") + + # Example 8: Update with variables + print("8. Updating question with variables:") + try: + if questions: + variables_update = j1.update_question( + question_id=question_id, + variables=[ + { + "name": "environment", + "required": True, + "default": "production" + }, + { + "name": "severity", + "required": False, + "default": "high" + } + ] + ) + + print(f" āœ… Successfully updated variables!") + if 'variables' in variables_update: + print(f" Number of variables: {len(variables_update['variables'])}") + for var in variables_update['variables']: + print(f" - {var['name']} (required: {var.get('required', False)}, default: {var.get('default', 'None')})") + print() + + except Exception as e: + print(f" āŒ Error updating variables: {e}\n") + def question_use_cases(j1): """Demonstrate real-world use cases for questions.""" @@ -492,6 +683,42 @@ def question_use_cases(j1): pollingInterval="ONE_WEEK" ) """) + + # Use Case 4: Question Maintenance and Updates + print("\nUse Case 4: Question Maintenance and Updates") + print("-" * 50) + print("Maintain and update existing questions:") + print(""" + # Update question title and description + updated_question = j1.update_question( + question_id="existing-question-id", + title="Updated Security Question Title", + description="Updated description with new security requirements" + ) + + # Update queries for better performance + updated_question = j1.update_question( + question_id="existing-question-id", + queries=[ + { + "name": "ImprovedQuery", + "query": "FIND * WITH tag.Security='critical' LIMIT 1000", + "version": "v2", + "resultsAre": "BAD" + } + ] + ) + + # Add compliance metadata + updated_question = j1.update_question( + question_id="existing-question-id", + compliance={ + "standard": "ISO 27001", + "requirements": ["A.9.1", "A.9.2"], + "controls": ["Access Control"] + } + ) + """) def main(): """Run all question management examples.""" @@ -518,6 +745,10 @@ def main(): time.sleep(1) get_question_details_example(j1) + time.sleep(1) + + update_question_examples(j1) + time.sleep(1) question_use_cases(j1) diff --git a/jupiterone/client.py b/jupiterone/client.py index fa2306e..c67a5eb 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -58,6 +58,7 @@ UPSERT_PARAMETER, UPDATE_ENTITYV2, INVOKE_INTEGRATION_INSTANCE, + UPDATE_QUESTION, ) class JupiterOneClient: @@ -1447,6 +1448,100 @@ def create_question( return response["data"]["createQuestion"] + def update_question( + self, + question_id: str, + title: str = None, + description: str = None, + queries: List[Dict] = None, + tags: List[str] = None, + **kwargs + ) -> Dict: + """ + Update an existing question in the J1 account. + + Args: + question_id (str): The unique ID of the question to update (required) + title (str, optional): New title for the question + description (str, optional): New description for the question + queries (List[Dict], optional): Updated list of queries + tags (List[str], optional): Updated list of tags + **kwargs: Additional optional parameters: + - compliance (Dict): Compliance metadata + - variables (List[Dict]): Variable definitions for the queries + - showTrend (bool): Whether to show trend data + - pollingInterval (str): How often to run the queries + + Returns: + Dict: The updated question object + + Raises: + ValueError: If question_id is not provided + JupiterOneApiError: If the question update fails or other API errors occur + + Example: + # Update question title and description + updated_question = j1_client.update_question( + question_id="fcc0507d-0473-43a2-b083-9d5571b92ae7", + title="Environment-Specific Resource Audit - UPDATED", + description="Audit resources by environment and cost center tags" + ) + + # Update queries and tags + updated_question = j1_client.update_question( + question_id="fcc0507d-0473-43a2-b083-9d5571b92ae7", + queries=[{ + "name": "EnvironmentResources", + "query": "FIND * WITH tag.Production = true", + "version": None, + "resultsAre": "INFORMATIVE" + }], + tags=["audit", "tagging", "cost-management"] + ) + + # Comprehensive update + updated_question = j1_client.update_question( + question_id="fcc0507d-0473-43a2-b083-9d5571b92ae7", + title="Environment-Specific Resource Audit - UPDATED", + description="Audit resources by environment and cost center tags", + queries=[{ + "name": "EnvironmentResources", + "query": "FIND * WITH tag.Production = true", + "version": None, + "resultsAre": "INFORMATIVE" + }], + tags=["audit", "tagging", "cost-management"] + ) + """ + if not question_id: + raise ValueError("question_id is required") + + # Build the update object with only provided fields + update_data = {} + + if title is not None: + update_data["title"] = title + if description is not None: + update_data["description"] = description + if queries is not None: + update_data["queries"] = queries + if tags is not None: + update_data["tags"] = tags + + # Add any additional fields from kwargs + for key, value in kwargs.items(): + if value is not None: + update_data[key] = value + + # Execute the GraphQL mutation + variables = { + "id": question_id, + "update": update_data + } + + response = self._execute_query(UPDATE_QUESTION, variables) + return response["data"]["updateQuestion"] + def get_compliance_framework_item_details(self, item_id: str = None): """Fetch Details of a Compliance Framework Requirement configured in J1 account""" variables = {"input": {"id": item_id}} diff --git a/jupiterone/constants.py b/jupiterone/constants.py index 6d606ac..beaae25 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -1326,3 +1326,47 @@ } } """ + +UPDATE_QUESTION = """ + mutation UpdateQuestion($id: ID!, $update: QuestionUpdate!) { + updateQuestion(id: $id, update: $update) { + ...QuestionFields + __typename + } + } + + fragment QuestionFields on Question { + id + sourceId + title + name + description + tags + lastUpdatedTimestamp + queries { + name + query + version + resultsAre + __typename + } + compliance { + standard + requirements + controls + __typename + } + variables { + name + required + default + __typename + } + tags + accountId + integrationDefinitionId + showTrend + pollingInterval + __typename + } +""" From 14657efcc14cc7af1c4d31b78157c87463f44ddb Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 13:47:44 -0600 Subject: [PATCH 16/22] add delete_question() --- examples/08_questions_management.py | 75 +++++++++++++++++++++++++++++ jupiterone/client.py | 41 ++++++++++++++++ jupiterone/constants.py | 34 +++++++++++++ 3 files changed, 150 insertions(+) diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py index c127d7a..54c4f8d 100644 --- a/examples/08_questions_management.py +++ b/examples/08_questions_management.py @@ -628,6 +628,44 @@ def update_question_examples(j1): except Exception as e: print(f" āŒ Error updating variables: {e}\n") +def delete_question_examples(j1): + """Demonstrate deleting existing questions.""" + + print("=== Delete Question Examples ===\n") + + # Get question ID from user input + print("1. Enter the question ID to delete:") + print(" (You can find question IDs by running the list_questions_example first)") + print() + + # For demonstration purposes, use a placeholder ID + # In a real application, you would use: question_id = input("Enter question ID: ") + question_id = "your-question-id-here" # Replace with actual question ID + + if question_id == "your-question-id-here": + print(" āš ļø Please replace 'your-question-id-here' with an actual question ID") + print(" Example: question_id = 'fcc0507d-0473-43a2-b083-9d5571b92ae7'") + print() + return + + print(f" Question ID to delete: {question_id}") + print() + + # Delete the question + print("2. Deleting the question:") + try: + deleted_question = j1.delete_question(question_id=question_id) + + print(f" āœ… Successfully deleted question!") + print(f" Deleted question title: {deleted_question['title']}") + print(f" Deleted question ID: {deleted_question['id']}") + print(f" Number of queries in deleted question: {len(deleted_question['queries'])}") + print() + + except Exception as e: + print(f" āŒ Error deleting question: {e}") + print() + def question_use_cases(j1): """Demonstrate real-world use cases for questions.""" @@ -719,6 +757,40 @@ def question_use_cases(j1): } ) """) + + # Use Case 5: Question Deletion and Cleanup + print("\nUse Case 5: Question Deletion and Cleanup") + print("-" * 50) + print("Delete questions that are no longer needed:") + print(""" + # Delete a single question + deleted_question = j1.delete_question( + question_id="question-id-to-delete" + ) + print(f"Deleted: {deleted_question['title']}") + + # Batch delete deprecated questions + deprecated_questions = j1.list_questions(tags=["deprecated"]) + for question in deprecated_questions: + try: + deleted = j1.delete_question(question_id=question['id']) + print(f"Deleted deprecated question: {deleted['title']}") + except Exception as e: + print(f"Failed to delete {question['title']}: {e}") + + # Safe deletion with backup + question_to_delete = j1.get_question_details(question_id="question-id") + backup_question = j1.create_question( + title=f"{question_to_delete['title']} - BACKUP", + queries=question_to_delete['queries'], + description=f"Backup before deletion: {question_to_delete.get('description', '')}", + tags=question_to_delete.get('tags', []) + ["backup"] + ) + + # Now delete the original + j1.delete_question(question_id="question-id") + print("Original question deleted, backup preserved") + """) def main(): """Run all question management examples.""" @@ -750,6 +822,9 @@ def main(): update_question_examples(j1) time.sleep(1) + delete_question_examples(j1) + time.sleep(1) + question_use_cases(j1) print("\n" + "=" * 50) diff --git a/jupiterone/client.py b/jupiterone/client.py index c67a5eb..7e75942 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -59,6 +59,7 @@ UPDATE_ENTITYV2, INVOKE_INTEGRATION_INSTANCE, UPDATE_QUESTION, + DELETE_QUESTION, ) class JupiterOneClient: @@ -1542,6 +1543,46 @@ def update_question( response = self._execute_query(UPDATE_QUESTION, variables) return response["data"]["updateQuestion"] + def delete_question(self, question_id: str) -> Dict: + """ + Delete an existing question from the J1 account. + + Args: + question_id (str): The unique ID of the question to delete (required) + + Returns: + Dict: The deleted question object with all its details + + Raises: + ValueError: If question_id is not provided + JupiterOneApiError: If the question deletion fails or other API errors occur + + Example: + # Delete a question by ID + deleted_question = j1_client.delete_question( + question_id="fcc0507d-0473-43a2-b083-9d5571b92ae7" + ) + + print(f"Question '{deleted_question['title']}' has been deleted") + print(f"Deleted question ID: {deleted_question['id']}") + print(f"Number of queries in deleted question: {len(deleted_question['queries'])}") + + # Access other deleted question details + if deleted_question.get('compliance'): + print(f"Compliance standard: {deleted_question['compliance']['standard']}") + + if deleted_question.get('tags'): + print(f"Tags: {', '.join(deleted_question['tags'])}") + """ + if not question_id: + raise ValueError("question_id is required") + + # Execute the GraphQL mutation + variables = {"id": question_id} + + response = self._execute_query(DELETE_QUESTION, variables) + return response["data"]["deleteQuestion"] + def get_compliance_framework_item_details(self, item_id: str = None): """Fetch Details of a Compliance Framework Requirement configured in J1 account""" variables = {"input": {"id": item_id}} diff --git a/jupiterone/constants.py b/jupiterone/constants.py index beaae25..fb78d8c 100644 --- a/jupiterone/constants.py +++ b/jupiterone/constants.py @@ -1370,3 +1370,37 @@ __typename } """ + +DELETE_QUESTION = """ + mutation DeleteQuestion($id: ID!) { + deleteQuestion(id: $id) { + id + title + description + queries { + query + name + version + __typename + } + compliance { + standard + requirements + controls + __typename + } + variables { + name + required + default + __typename + } + tags + accountId + integrationDefinitionId + showTrend + pollingInterval + __typename + } + } +""" From 23fa34ce34b44c175ecc8fc2840c6510a9be9966 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 13:48:19 -0600 Subject: [PATCH 17/22] update to 1.6.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ae20b39..a66ca36 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="jupiterone", - version="1.5.0", + version="1.6.0", description="A Python client for the JupiterOne API", license="MIT License", author="JupiterOne", From 802dc6b84ce9a6867ae9af9008e2e00f8a65acc3 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 13:49:53 -0600 Subject: [PATCH 18/22] Delete create_question_usage.md --- docs/create_question_usage.md | 195 ---------------------------------- 1 file changed, 195 deletions(-) delete mode 100644 docs/create_question_usage.md diff --git a/docs/create_question_usage.md b/docs/create_question_usage.md deleted file mode 100644 index a4396ab..0000000 --- a/docs/create_question_usage.md +++ /dev/null @@ -1,195 +0,0 @@ -# Create Question Method Documentation - -## Overview - -The `create_question` method allows you to programmatically create questions in your JupiterOne account. Questions are saved queries that can be run on-demand or on a schedule to monitor your infrastructure and security posture. - -## Method Signature - -```python -def create_question( - self, - title: str, - queries: List[Dict], - resource_group_id: str = None, - **kwargs -) -``` - -## Parameters - -### Required Parameters - -- **`title`** (str): The title of the question. This is required and cannot be empty. - -- **`queries`** (List[Dict]): A list of query objects. At least one query is required. Each query object can contain: - - **`query`** (str, required): The J1QL query string - - **`name`** (str, optional): Name for the query. Defaults to "Query{index}" - - **`version`** (str, optional): Query version (e.g., "v1") - - **`resultsAre`** (str, optional): Query result type. Defaults to "INFORMATIVE". Options include: - - "INFORMATIVE" - Neutral information - - "GOOD" - Positive/expected results - - "BAD" - Negative/unexpected results - - "UNKNOWN" - Unknown state - -### Optional Parameters - -- **`resource_group_id`** (str): ID of the resource group to associate the question with - -### Additional Keyword Arguments (**kwargs) - -- **`description`** (str): Description of the question -- **`tags`** (List[str]): List of tags to apply to the question -- **`compliance`** (Dict): Compliance metadata containing: - - `standard` (str): Compliance standard name - - `requirements` (List[str]): List of requirement IDs - - `controls` (List[str]): List of control names -- **`variables`** (List[Dict]): Variable definitions for parameterized queries -- **`showTrend`** (bool): Whether to show trend data for the question results -- **`pollingInterval`** (str): How often to run the queries (e.g., "ONE_HOUR", "ONE_DAY") -- **`integrationDefinitionId`** (str): Integration definition ID if the question is integration-specific - -## Return Value - -Returns a dictionary containing the created question object with fields like: -- `id`: Unique identifier for the question -- `title`: The question title -- `description`: The question description -- `queries`: List of query objects -- `tags`: Applied tags -- And other metadata fields - -## Examples - -### Basic Question - -```python -from jupiterone import JupiterOneClient - -j1_client = JupiterOneClient( - account="your-account-id", - token="your-api-token" -) - -# Create a simple question -question = j1_client.create_question( - title="Find All Open Hosts", - queries=[{ - "query": "FIND Host WITH open=true", - "name": "OpenHosts" - }] -) -``` - -### Question with Multiple Queries - -```python -# Create a question with multiple queries for comprehensive checks -question = j1_client.create_question( - title="Security Compliance Check", - queries=[ - { - "query": "FIND Host WITH open=true", - "name": "OpenHosts", - "resultsAre": "BAD" - }, - { - "query": "FIND User WITH mfaEnabled=false", - "name": "UsersWithoutMFA", - "resultsAre": "BAD" - }, - { - "query": "FIND DataStore WITH encrypted=false", - "name": "UnencryptedDataStores", - "resultsAre": "BAD" - } - ], - description="Comprehensive security compliance check", - tags=["security", "compliance", "audit"] -) -``` - -### Advanced Question with All Options - -```python -# Create a question with all optional parameters -question = j1_client.create_question( - title="AWS Security Audit", - queries=[{ - "query": "FIND aws_instance WITH publicIpAddress!=undefined", - "name": "PublicInstances", - "version": "v1", - "resultsAre": "INFORMATIVE" - }], - resource_group_id="resource-group-123", - description="Audit AWS instances with public IP addresses", - tags=["aws", "security", "network"], - showTrend=True, - pollingInterval="ONE_DAY", - compliance={ - "standard": "CIS", - "requirements": ["2.1", "2.2"], - "controls": ["Network Security"] - }, - variables=[ - { - "name": "environment", - "required": False, - "default": "production" - } - ] -) -``` - -### Minimal Question - -```python -# Create a question with only required parameters -# The query name will default to "Query0" and resultsAre to "INFORMATIVE" -question = j1_client.create_question( - title="Simple Query", - queries=[{ - "query": "FIND User LIMIT 10" - }] -) -``` - -## Error Handling - -The method includes validation for required fields and will raise `ValueError` exceptions for: -- Missing or empty `title` -- Missing or empty `queries` list -- Invalid query format (not a dictionary) -- Missing required `query` field in query objects - -```python -try: - question = j1_client.create_question( - title="", # This will raise an error - queries=[] - ) -except ValueError as e: - print(f"Error: {e}") -``` - -## GraphQL Mutation - -The method uses the following GraphQL mutation internally: - -```graphql -mutation CreateQuestion($question: CreateQuestionInput!) { - createQuestion(question: $question) { - id - title - description - tags - queries { - name - query - version - resultsAre - } - # ... other fields - } -} -``` From 711d42d64c1bd64cbb1a64aeb4402d0c20f30186 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 13:57:32 -0600 Subject: [PATCH 19/22] created tests --- tests/test_cft_methods.py | 270 +++++++++++++++++++++++ tests/test_delete_entity.py | 124 ++++++++++- tests/test_question_management.py | 349 ++++++++++++++++++++++++++++++ 3 files changed, 731 insertions(+), 12 deletions(-) create mode 100644 tests/test_cft_methods.py create mode 100644 tests/test_question_management.py diff --git a/tests/test_cft_methods.py b/tests/test_cft_methods.py new file mode 100644 index 0000000..e7da342 --- /dev/null +++ b/tests/test_cft_methods.py @@ -0,0 +1,270 @@ +"""Test Custom File Transfer (CFT) methods""" + +import pytest +import responses +import tempfile +import os +from unittest.mock import Mock, patch, mock_open +from jupiterone.client import JupiterOneClient +from jupiterone.errors import JupiterOneApiError +from jupiterone.constants import INVOKE_INTEGRATION_INSTANCE + + +class TestCFTMethods: + """Test Custom File Transfer (CFT) methods""" + + def setup_method(self): + """Set up test fixtures""" + self.client = JupiterOneClient(account="test-account", token="test-token") + self.integration_instance_id = "test-integration-instance-123" + self.dataset_id = "test-dataset-456" + self.filename = "test_data.csv" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_cft_upload_url_success(self, mock_execute_query): + """Test successful CFT upload URL retrieval""" + mock_response = { + "data": { + "integrationFileTransferUploadUrl": { + "uploadUrl": "https://s3.amazonaws.com/test-bucket/test-file.csv", + "expiresAt": "2024-01-01T12:00:00Z" + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.get_cft_upload_url( + integration_instance_id=self.integration_instance_id, + filename=self.filename, + dataset_id=self.dataset_id + ) + + assert result == mock_response["data"]["integrationFileTransferUploadUrl"] + mock_execute_query.assert_called_once() + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_cft_upload_url_error(self, mock_execute_query): + """Test CFT upload URL retrieval with error""" + mock_execute_query.side_effect = JupiterOneApiError("API Error") + + with pytest.raises(JupiterOneApiError): + self.client.get_cft_upload_url( + integration_instance_id=self.integration_instance_id, + filename=self.filename, + dataset_id=self.dataset_id + ) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_get_cft_upload_url_missing_data(self, mock_execute_query): + """Test CFT upload URL retrieval with missing data in response""" + mock_response = { + "data": { + "integrationFileTransferUploadUrl": None + } + } + mock_execute_query.return_value = mock_response + + result = self.client.get_cft_upload_url( + integration_instance_id=self.integration_instance_id, + filename=self.filename, + dataset_id=self.dataset_id + ) + + assert result is None + + @patch('builtins.open', new_callable=mock_open, read_data="test,csv,data") + @patch('requests.Session.put') + def test_upload_cft_file_csv_success(self, mock_put, mock_file): + """Test successful CSV file upload""" + # Mock successful HTTP response + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {'Content-Type': 'text/csv'} + mock_response.text = '{"status": "success"}' + mock_response.json.return_value = {"status": "success"} + mock_put.return_value = mock_response + + # Create a temporary file path + with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as temp_file: + temp_file.write(b"test,csv,data") + temp_file_path = temp_file.name + + try: + result = self.client.upload_cft_file( + upload_url="https://s3.amazonaws.com/test-bucket/test-file.csv", + file_path=temp_file_path + ) + + # Verify the result structure + assert result['status_code'] == 200 + assert result['success'] is True + assert result['response_data'] == {"status": "success"} + assert 'Content-Type' in result['headers'] + assert result['headers']['Content-Type'] == 'text/csv' + + # Verify the PUT request was made correctly + mock_put.assert_called_once() + call_args = mock_put.call_args + assert call_args[0][0] == "https://s3.amazonaws.com/test-bucket/test-file.csv" + assert call_args[1]['headers']['Content-Type'] == 'text/csv' + assert call_args[1]['timeout'] == 300 + + finally: + # Clean up temporary file + os.unlink(temp_file_path) + + @patch('builtins.open', new_callable=mock_open, read_data="test,csv,data") + @patch('requests.Session.put') + def test_upload_cft_file_csv_error_response(self, mock_put, mock_file): + """Test CSV file upload with error response""" + # Mock error HTTP response + mock_response = Mock() + mock_response.status_code = 400 + mock_response.headers = {'Content-Type': 'application/json'} + mock_response.text = '{"error": "Bad Request"}' + mock_response.json.return_value = {"error": "Bad Request"} + mock_put.return_value = mock_response + + # Create a temporary file path + with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as temp_file: + temp_file.write(b"test,csv,data") + temp_file_path = temp_file.name + + try: + result = self.client.upload_cft_file( + upload_url="https://s3.amazonaws.com/test-bucket/test-file.csv", + file_path=temp_file_path + ) + + # Verify the result structure for error case + assert result['status_code'] == 400 + assert result['success'] is False + assert result['response_data'] == {"error": "Bad Request"} + + finally: + # Clean up temporary file + os.unlink(temp_file_path) + + def test_upload_cft_file_nonexistent_file(self): + """Test CSV file upload with nonexistent file""" + with pytest.raises(FileNotFoundError): + self.client.upload_cft_file( + upload_url="https://s3.amazonaws.com/test-bucket/test-file.csv", + file_path="/nonexistent/file.csv" + ) + + def test_upload_cft_file_non_csv_extension(self): + """Test file upload with non-CSV extension""" + with tempfile.NamedTemporaryFile(suffix='.txt', delete=False) as temp_file: + temp_file.write(b"test data") + temp_file_path = temp_file.name + + try: + with pytest.raises(ValueError, match="Only CSV files are supported"): + self.client.upload_cft_file( + upload_url="https://s3.amazonaws.com/test-bucket/test-file.csv", + file_path=temp_file_path + ) + finally: + os.unlink(temp_file_path) + + def test_upload_cft_file_csv_extension_case_insensitive(self): + """Test file upload with CSV extension in different cases""" + # Test uppercase .CSV + with tempfile.NamedTemporaryFile(suffix='.CSV', delete=False) as temp_file: + temp_file.write(b"test,csv,data") + temp_file_path = temp_file.name + + try: + with patch('requests.Session.put') as mock_put: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {'Content-Type': 'text/csv'} + mock_response.text = '{"status": "success"}' + mock_response.json.return_value = {"status": "success"} + mock_put.return_value = mock_response + + result = self.client.upload_cft_file( + upload_url="https://s3.amazonaws.com/test-bucket/test-file.csv", + file_path=temp_file_path + ) + + assert result['success'] is True + + finally: + os.unlink(temp_file_path) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_invoke_cft_integration_success(self, mock_execute_query): + """Test successful CFT integration invocation""" + mock_response = { + "data": { + "invokeIntegrationInstance": { + "success": True, + "integrationJobId": "job-123" + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.invoke_cft_integration(self.integration_instance_id) + + assert result == "job-123" + mock_execute_query.assert_called_once_with( + INVOKE_INTEGRATION_INSTANCE, + {"id": self.integration_instance_id} + ) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_invoke_cft_integration_already_executing(self, mock_execute_query): + """Test CFT integration invocation when already executing""" + mock_response = { + "data": { + "invokeIntegrationInstance": { + "success": False, + "integrationJobId": None + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.invoke_cft_integration(self.integration_instance_id) + + assert result is False + mock_execute_query.assert_called_once() + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_invoke_cft_integration_api_error(self, mock_execute_query): + """Test CFT integration invocation with API error""" + mock_execute_query.side_effect = JupiterOneApiError("API Error") + + with pytest.raises(JupiterOneApiError): + self.client.invoke_cft_integration(self.integration_instance_id) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_invoke_cft_integration_unexpected_response(self, mock_execute_query): + """Test CFT integration invocation with unexpected response structure""" + mock_response = { + "data": { + "invokeIntegrationInstance": { + "success": True, + "integrationJobId": None # Unexpected: success but no job ID + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.invoke_cft_integration(self.integration_instance_id) + + # Should return False when success is True but no job ID + assert result is False + + def test_invoke_cft_integration_empty_instance_id(self): + """Test CFT integration invocation with empty instance ID""" + with pytest.raises(ValueError, match="integration_instance_id is required"): + self.client.invoke_cft_integration("") + + def test_invoke_cft_integration_none_instance_id(self): + """Test CFT integration invocation with None instance ID""" + with pytest.raises(ValueError, match="integration_instance_id is required"): + self.client.invoke_cft_integration(None) diff --git a/tests/test_delete_entity.py b/tests/test_delete_entity.py index 73348a1..8b9d4bc 100644 --- a/tests/test_delete_entity.py +++ b/tests/test_delete_entity.py @@ -3,29 +3,24 @@ import responses from jupiterone.client import JupiterOneClient -from jupiterone.constants import CREATE_ENTITY +from jupiterone.constants import DELETE_ENTITY @responses.activate -def test_tree_query_v1(): - +def test_delete_entity_basic(): + """Test basic entity deletion with deleteEntityV2""" + def request_callback(request): headers = { 'Content-Type': 'application/json' } response = { 'data': { - 'deleteEntity': { + 'deleteEntityV2': { 'entity': { '_id': '1' }, - 'vertex': { - 'id': '1', - 'entity': { - '_id': '1' - }, - 'properties': {} - } + '__typename': 'DeleteEntityResult' } } } @@ -43,5 +38,110 @@ def request_callback(request): assert type(response) == dict assert type(response['entity']) == dict - assert type(response['vertex']) == dict assert response['entity']['_id'] == '1' + assert response['__typename'] == 'DeleteEntityResult' + + +@responses.activate +def test_delete_entity_with_timestamp(): + """Test entity deletion with timestamp parameter""" + + def request_callback(request): + headers = { + 'Content-Type': 'application/json' + } + response = { + 'data': { + 'deleteEntityV2': { + 'entity': { + '_id': '2' + }, + '__typename': 'DeleteEntityResult' + } + } + } + + return (200, headers, json.dumps(response)) + + responses.add_callback( + responses.POST, 'https://graphql.us.jupiterone.io', + callback=request_callback, + content_type='application/json', + ) + + j1 = JupiterOneClient(account='testAccount', token='testToken') + response = j1.delete_entity('2', timestamp=1640995200000) + + assert type(response) == dict + assert type(response['entity']) == dict + assert response['entity']['_id'] == '2' + + +@responses.activate +def test_delete_entity_with_hard_delete(): + """Test entity deletion with hardDelete parameter""" + + def request_callback(request): + headers = { + 'Content-Type': 'application/json' + } + response = { + 'data': { + 'deleteEntityV2': { + 'entity': { + '_id': '3' + }, + '__typename': 'DeleteEntityResult' + } + } + } + + return (200, headers, json.dumps(response)) + + responses.add_callback( + responses.POST, 'https://graphql.us.jupiterone.io', + callback=request_callback, + content_type='application/json', + ) + + j1 = JupiterOneClient(account='testAccount', token='testToken') + response = j1.delete_entity('3', hard_delete=False) + + assert type(response) == dict + assert type(response['entity']) == dict + assert response['entity']['_id'] == '3' + + +@responses.activate +def test_delete_entity_with_all_parameters(): + """Test entity deletion with all parameters""" + + def request_callback(request): + headers = { + 'Content-Type': 'application/json' + } + response = { + 'data': { + 'deleteEntityV2': { + 'entity': { + '_id': '4' + }, + '__typename': 'DeleteEntityResult' + } + } + } + + return (200, headers, json.dumps(response)) + + responses.add_callback( + responses.POST, 'https://graphql.us.jupiterone.io', + callback=request_callback, + content_type='application/json', + ) + + j1 = JupiterOneClient(account='testAccount', token='testToken') + response = j1.delete_entity('4', timestamp=1640995200000, hard_delete=True) + + assert type(response) == dict + assert type(response['entity']) == dict + assert response['entity']['_id'] == '4' diff --git a/tests/test_question_management.py b/tests/test_question_management.py new file mode 100644 index 0000000..0c7ae0d --- /dev/null +++ b/tests/test_question_management.py @@ -0,0 +1,349 @@ +"""Test question management methods""" + +import pytest +from unittest.mock import patch +from jupiterone.client import JupiterOneClient +from jupiterone.errors import JupiterOneApiError +from jupiterone.constants import UPDATE_QUESTION, DELETE_QUESTION + + +class TestQuestionManagement: + """Test question management methods""" + + def setup_method(self): + """Set up test fixtures""" + self.client = JupiterOneClient(account="test-account", token="test-token") + self.question_id = "test-question-123" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_title_only(self, mock_execute_query): + """Test updating question title only""" + mock_response = { + "data": { + "updateQuestion": { + "id": self.question_id, + "title": "Updated Question Title", + "description": "Original description", + "queries": [{"name": "Query0", "query": "FIND Host"}], + "tags": ["original", "tag"] + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.update_question( + question_id=self.question_id, + title="Updated Question Title" + ) + + # Verify the call + mock_execute_query.assert_called_once() + call_args = mock_execute_query.call_args + + # Check the mutation was called with correct parameters + assert call_args[0][0] == UPDATE_QUESTION + + # Check variables + variables = call_args[1]['variables'] + assert variables['id'] == self.question_id + assert variables['update']['title'] == "Updated Question Title" + assert 'description' not in variables['update'] + assert 'queries' not in variables['update'] + assert 'tags' not in variables['update'] + + # Check result + assert result['id'] == self.question_id + assert result['title'] == "Updated Question Title" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_description_only(self, mock_execute_query): + """Test updating question description only""" + mock_response = { + "data": { + "updateQuestion": { + "id": self.question_id, + "title": "Original Title", + "description": "Updated description", + "queries": [{"name": "Query0", "query": "FIND Host"}], + "tags": ["original", "tag"] + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.update_question( + question_id=self.question_id, + description="Updated description" + ) + + # Verify the call + mock_execute_query.assert_called_once() + call_args = mock_execute_query.call_args + + # Check variables + variables = call_args[1]['variables'] + assert variables['id'] == self.question_id + assert variables['update']['description'] == "Updated description" + assert 'title' not in variables['update'] + assert 'queries' not in variables['update'] + assert 'tags' not in variables['update'] + + # Check result + assert result['description'] == "Updated description" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_tags_only(self, mock_execute_query): + """Test updating question tags only""" + new_tags = ["security", "compliance", "updated"] + mock_response = { + "data": { + "updateQuestion": { + "id": self.question_id, + "title": "Original Title", + "description": "Original description", + "queries": [{"name": "Query0", "query": "FIND Host"}], + "tags": new_tags + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.update_question( + question_id=self.question_id, + tags=new_tags + ) + + # Verify the call + mock_execute_query.assert_called_once() + call_args = mock_execute_query.call_args + + # Check variables + variables = call_args[1]['variables'] + assert variables['id'] == self.question_id + assert variables['update']['tags'] == new_tags + assert 'title' not in variables['update'] + assert 'description' not in variables['update'] + assert 'queries' not in variables['update'] + + # Check result + assert result['tags'] == new_tags + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_queries_only(self, mock_execute_query): + """Test updating question queries only""" + new_queries = [ + {"name": "UpdatedQuery", "query": "FIND User WITH active=true", "version": "v2"} + ] + mock_response = { + "data": { + "updateQuestion": { + "id": self.question_id, + "title": "Original Title", + "description": "Original description", + "queries": new_queries, + "tags": ["original", "tag"] + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.update_question( + question_id=self.question_id, + queries=new_queries + ) + + # Verify the call + mock_execute_query.assert_called_once() + call_args = mock_execute_query.call_args + + # Check variables + variables = call_args[1]['variables'] + assert variables['id'] == self.question_id + assert variables['update']['queries'] == new_queries + assert 'title' not in variables['update'] + assert 'description' not in variables['update'] + assert 'tags' not in variables['update'] + + # Check result + assert result['queries'] == new_queries + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_comprehensive(self, mock_execute_query): + """Test updating question with multiple fields""" + update_data = { + "title": "Comprehensive Updated Title", + "description": "Comprehensive updated description", + "tags": ["comprehensive", "update", "test"], + "queries": [ + {"name": "ComprehensiveQuery", "query": "FIND * WITH _class='Host'", "version": "v3"} + ] + } + + mock_response = { + "data": { + "updateQuestion": { + "id": self.question_id, + **update_data + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.update_question( + question_id=self.question_id, + **update_data + ) + + # Verify the call + mock_execute_query.assert_called_once() + call_args = mock_execute_query.call_args + + # Check variables + variables = call_args[1]['variables'] + assert variables['id'] == self.question_id + assert variables['update']['title'] == update_data['title'] + assert variables['update']['description'] == update_data['description'] + assert variables['update']['tags'] == update_data['tags'] + assert variables['update']['queries'] == update_data['queries'] + + # Check result + assert result['title'] == update_data['title'] + assert result['description'] == update_data['description'] + assert result['tags'] == update_data['tags'] + assert result['queries'] == update_data['queries'] + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_with_kwargs(self, mock_execute_query): + """Test updating question with additional kwargs""" + mock_response = { + "data": { + "updateQuestion": { + "id": self.question_id, + "title": "Original Title", + "showTrend": True, + "pollingInterval": "ONE_HOUR" + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.update_question( + question_id=self.question_id, + showTrend=True, + pollingInterval="ONE_HOUR" + ) + + # Verify the call + mock_execute_query.assert_called_once() + call_args = mock_execute_query.call_args + + # Check variables + variables = call_args[1]['variables'] + assert variables['id'] == self.question_id + assert variables['update']['showTrend'] is True + assert variables['update']['pollingInterval'] == "ONE_HOUR" + + # Check result + assert result['showTrend'] is True + assert result['update']['pollingInterval'] == "ONE_HOUR" + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_api_error(self, mock_execute_query): + """Test updating question with API error""" + mock_execute_query.side_effect = JupiterOneApiError("API Error") + + with pytest.raises(JupiterOneApiError): + self.client.update_question( + question_id=self.question_id, + title="Updated Title" + ) + + def test_update_question_empty_question_id(self): + """Test updating question with empty question ID""" + with pytest.raises(ValueError, match="question_id is required"): + self.client.update_question( + question_id="", + title="Updated Title" + ) + + def test_update_question_none_question_id(self): + """Test updating question with None question ID""" + with pytest.raises(ValueError, match="question_id is required"): + self.client.update_question( + question_id=None, + title="Updated Title" + ) + + def test_update_question_no_update_fields(self): + """Test updating question with no update fields""" + with pytest.raises(ValueError, match="At least one update field must be provided"): + self.client.update_question(question_id=self.question_id) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_delete_question_success(self, mock_execute_query): + """Test successful question deletion""" + mock_response = { + "data": { + "deleteQuestion": { + "id": self.question_id, + "title": "Question to Delete", + "description": "This question will be deleted", + "queries": [{"name": "Query0", "query": "FIND Host"}], + "tags": ["delete", "test"], + "accountId": "test-account" + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.delete_question(question_id=self.question_id) + + # Verify the call + mock_execute_query.assert_called_once() + call_args = mock_execute_query.call_args + + # Check the mutation was called with correct parameters + assert call_args[0][0] == DELETE_QUESTION + + # Check variables + variables = call_args[1]['variables'] + assert variables['id'] == self.question_id + + # Check result + assert result['id'] == self.question_id + assert result['title'] == "Question to Delete" + assert result['description'] == "This question will be deleted" + assert len(result['queries']) == 1 + assert result['tags'] == ["delete", "test"] + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_delete_question_api_error(self, mock_execute_query): + """Test deleting question with API error""" + mock_execute_query.side_effect = JupiterOneApiError("API Error") + + with pytest.raises(JupiterOneApiError): + self.client.delete_question(question_id=self.question_id) + + def test_delete_question_empty_question_id(self): + """Test deleting question with empty question ID""" + with pytest.raises(ValueError, match="question_id is required"): + self.client.delete_question(question_id="") + + def test_delete_question_none_question_id(self): + """Test deleting question with None question ID""" + with pytest.raises(ValueError, match="question_id is required"): + self.client.delete_question(question_id=None) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_delete_question_nonexistent_question(self, mock_execute_query): + """Test deleting nonexistent question""" + mock_response = { + "data": { + "deleteQuestion": None + } + } + mock_execute_query.return_value = mock_response + + result = self.client.delete_question(question_id="nonexistent-id") + + assert result is None From 61ddfb0a19afb50196e0e48cf9d12d4876d22783 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Tue, 19 Aug 2025 14:00:19 -0600 Subject: [PATCH 20/22] remove imports --- tests/test_cft_methods.py | 1 - tests/test_delete_entity.py | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/test_cft_methods.py b/tests/test_cft_methods.py index e7da342..5dd886f 100644 --- a/tests/test_cft_methods.py +++ b/tests/test_cft_methods.py @@ -1,7 +1,6 @@ """Test Custom File Transfer (CFT) methods""" import pytest -import responses import tempfile import os from unittest.mock import Mock, patch, mock_open diff --git a/tests/test_delete_entity.py b/tests/test_delete_entity.py index 8b9d4bc..16c123d 100644 --- a/tests/test_delete_entity.py +++ b/tests/test_delete_entity.py @@ -3,7 +3,6 @@ import responses from jupiterone.client import JupiterOneClient -from jupiterone.constants import DELETE_ENTITY @responses.activate From 2b12fe8a34f98124dcd9f3d742c509c2d4fed7e3 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Wed, 20 Aug 2025 09:52:03 -0600 Subject: [PATCH 21/22] remove resource_group from question methods --- examples/08_questions_management.py | 25 ++++----- jupiterone/client.py | 16 +++--- tests/test_cft_methods.py | 12 ++--- tests/test_create_question.py | 83 ++++++++++++++--------------- tests/test_question_management.py | 30 +++++------ 5 files changed, 80 insertions(+), 86 deletions(-) diff --git a/examples/08_questions_management.py b/examples/08_questions_management.py index 54c4f8d..5d40643 100644 --- a/examples/08_questions_management.py +++ b/examples/08_questions_management.py @@ -254,18 +254,14 @@ def advanced_question_examples(j1): except Exception as e: print(f"Error creating parameterized question: {e}\n") -def resource_group_question_examples(j1): - """Demonstrate questions with resource group associations.""" +def production_environment_examples(j1): + """Demonstrate questions for production environment monitoring.""" - print("=== Resource Group Question Examples ===\n") + print("=== Production Environment Question Examples ===\n") - # Note: You'll need to have a resource group ID for this example - # This is just a demonstration - replace with your actual resource group ID - resource_group_id = "your-resource-group-id" # Replace with actual ID - - print("1. Creating a question associated with a resource group:") + print("1. Creating a question for production environment security:") try: - rg_question = j1.create_question( + prod_question = j1.create_question( title="Production Environment Security Check", queries=[ { @@ -279,17 +275,18 @@ def resource_group_question_examples(j1): "resultsAre": "BAD" } ], - resource_group_id=resource_group_id, # Associate with resource group description="Security checks for production environment resources", tags=["production", "security", "critical"], pollingInterval="THIRTY_MINUTES" # More frequent polling for production ) - print(f"Created resource group question: {rg_question['title']}") - print(f"Resource group ID: {resource_group_id}") + print(f"Created production environment question: {prod_question['title']}") + print(f"Description: {prod_question['description']}") + print(f"Tags: {', '.join(prod_question.get('tags', []))}") + print(f"Polling interval: {prod_question.get('pollingInterval', 'Not set')}") print() except Exception as e: - print(f"Note: Resource group example requires valid resource group ID\n") + print(f"Error creating production environment question: {e}\n") def list_questions_example(j1): """Demonstrate listing existing questions.""" @@ -810,7 +807,7 @@ def main(): advanced_question_examples(j1) time.sleep(1) - resource_group_question_examples(j1) + production_environment_examples(j1) time.sleep(1) list_questions_example(j1) diff --git a/jupiterone/client.py b/jupiterone/client.py index 7e75942..3dcc51e 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -1358,7 +1358,6 @@ def create_question( self, title: str, queries: List[Dict], - resource_group_id: str = None, **kwargs ): """Creates a new Question in the J1 account. @@ -1370,7 +1369,6 @@ def create_question( - name (str): Name for the query - version (str): Query version (defaults to 'v1') - resultsAre (str): Query result type (defaults to 'INFORMATIVE') - resource_group_id (str, optional): ID of the resource group to associate with **kwargs: Additional optional parameters: - description (str): Description of the question - tags (List[str]): List of tags to apply to the question @@ -1392,7 +1390,6 @@ def create_question( "version": "v1", "resultsAre": "INFORMATIVE" }], - resource_group_id="resource-group-id", description="Check for open hosts", tags=["security", "compliance"] ) @@ -1429,11 +1426,7 @@ def create_question( "queries": processed_queries } - # Add optional fields from kwargs - if resource_group_id: - question_input["resourceGroupId"] = resource_group_id - - # Add other optional fields if provided + # Add optional fields if provided optional_fields = [ "description", "tags", "compliance", "variables", "showTrend", "pollingInterval", "integrationDefinitionId" @@ -1534,6 +1527,10 @@ def update_question( if value is not None: update_data[key] = value + # Validate that at least one update field is provided + if not update_data: + raise ValueError("At least one update field must be provided") + # Execute the GraphQL mutation variables = { "id": question_id, @@ -1810,6 +1807,9 @@ def invoke_cft_integration(self, integration_instance_id: str) -> Union[bool, st else: print("Integration invocation failed") """ + if not integration_instance_id: + raise ValueError("integration_instance_id is required") + variables = {"id": integration_instance_id} try: diff --git a/tests/test_cft_methods.py b/tests/test_cft_methods.py index 5dd886f..7f5930e 100644 --- a/tests/test_cft_methods.py +++ b/tests/test_cft_methods.py @@ -97,7 +97,7 @@ def test_upload_cft_file_csv_success(self, mock_put, mock_file): # Verify the result structure assert result['status_code'] == 200 assert result['success'] is True - assert result['response_data'] == {"status": "success"} + assert result['response_data'] == {'text': '{"status": "success"}'} assert 'Content-Type' in result['headers'] assert result['headers']['Content-Type'] == 'text/csv' @@ -138,7 +138,7 @@ def test_upload_cft_file_csv_error_response(self, mock_put, mock_file): # Verify the result structure for error case assert result['status_code'] == 400 assert result['success'] is False - assert result['response_data'] == {"error": "Bad Request"} + assert result['response_data'] == {'text': '{"error": "Bad Request"}'} finally: # Clean up temporary file @@ -159,7 +159,7 @@ def test_upload_cft_file_non_csv_extension(self): temp_file_path = temp_file.name try: - with pytest.raises(ValueError, match="Only CSV files are supported"): + with pytest.raises(ValueError, match="File must be a CSV file"): self.client.upload_cft_file( upload_url="https://s3.amazonaws.com/test-bucket/test-file.csv", file_path=temp_file_path @@ -208,7 +208,7 @@ def test_invoke_cft_integration_success(self, mock_execute_query): result = self.client.invoke_cft_integration(self.integration_instance_id) - assert result == "job-123" + assert result is True mock_execute_query.assert_called_once_with( INVOKE_INTEGRATION_INSTANCE, {"id": self.integration_instance_id} @@ -255,8 +255,8 @@ def test_invoke_cft_integration_unexpected_response(self, mock_execute_query): result = self.client.invoke_cft_integration(self.integration_instance_id) - # Should return False when success is True but no job ID - assert result is False + # Should return True when success is True (regardless of job ID) + assert result is True def test_invoke_cft_integration_empty_instance_id(self): """Test CFT integration invocation with empty instance ID""" diff --git a/tests/test_create_question.py b/tests/test_create_question.py index eb87ca0..8a318a4 100644 --- a/tests/test_create_question.py +++ b/tests/test_create_question.py @@ -45,19 +45,19 @@ def test_create_question_basic(self, mock_execute): call_args = mock_execute.call_args # Check the mutation was called with correct parameters - self.assertEqual(call_args[0][0], CREATE_QUESTION) + assert call_args[0][0] == CREATE_QUESTION # Check variables variables = call_args[1]['variables'] - self.assertEqual(variables['question']['title'], "Test Question") - self.assertEqual(len(variables['question']['queries']), 1) - self.assertEqual(variables['question']['queries'][0]['query'], "FIND Host") - self.assertEqual(variables['question']['queries'][0]['name'], "Query0") - self.assertEqual(variables['question']['queries'][0]['resultsAre'], "INFORMATIVE") + assert variables['question']['title'] == "Test Question" + assert len(variables['question']['queries']) == 1 + assert variables['question']['queries'][0]['query'] == "FIND Host" + assert variables['question']['queries'][0]['name'] == "Query0" + assert variables['question']['queries'][0]['resultsAre'] == "INFORMATIVE" # Check result - self.assertEqual(result['id'], "question-123") - self.assertEqual(result['title'], "Test Question") + assert result['id'] == "question-123" + assert result['title'] == "Test Question" @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_with_all_options(self, mock_execute): @@ -77,8 +77,7 @@ def test_create_question_with_all_options(self, mock_execute): }], "tags": ["security", "test"], "showTrend": True, - "pollingInterval": "ONE_HOUR", - "resourceGroupId": "rg-123" + "pollingInterval": "ONE_HOUR" } } } @@ -92,7 +91,6 @@ def test_create_question_with_all_options(self, mock_execute): "version": "v1", "resultsAre": "BAD" }], - resource_group_id="rg-123", description="Complex description", tags=["security", "test"], showTrend=True, @@ -103,16 +101,15 @@ def test_create_question_with_all_options(self, mock_execute): variables = mock_execute.call_args[1]['variables'] question_input = variables['question'] - self.assertEqual(question_input['title'], "Complex Question") - self.assertEqual(question_input['resourceGroupId'], "rg-123") - self.assertEqual(question_input['description'], "Complex description") - self.assertEqual(question_input['tags'], ["security", "test"]) - self.assertEqual(question_input['showTrend'], True) - self.assertEqual(question_input['pollingInterval'], "ONE_HOUR") + assert question_input['title'] == "Complex Question" + assert question_input['description'] == "Complex description" + assert question_input['tags'] == ["security", "test"] + assert question_input['showTrend'] == True + assert question_input['pollingInterval'] == "ONE_HOUR" # Check result - self.assertEqual(result['id'], "question-456") - self.assertEqual(result['showTrend'], True) + assert result['id'] == "question-456" + assert result['showTrend'] == True @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_with_compliance(self, mock_execute): @@ -151,12 +148,12 @@ def test_create_question_with_compliance(self, mock_execute): variables = mock_execute.call_args[1]['variables'] question_input = variables['question'] - self.assertEqual(question_input['compliance']['standard'], "CIS") - self.assertEqual(question_input['compliance']['requirements'], ["2.1", "2.2"]) - self.assertEqual(question_input['compliance']['controls'], ["Network Security"]) + assert question_input['compliance']['standard'] == "CIS" + assert question_input['compliance']['requirements'] == ["2.1", "2.2"] + assert question_input['compliance']['controls'] == ["Network Security"] # Check result - self.assertEqual(result['compliance']['standard'], "CIS") + assert result['compliance']['standard'] == "CIS" @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_with_variables(self, mock_execute): @@ -198,13 +195,13 @@ def test_create_question_with_variables(self, mock_execute): variables = mock_execute.call_args[1]['variables'] question_input = variables['question'] - self.assertEqual(len(question_input['variables']), 1) - self.assertEqual(question_input['variables'][0]['name'], "environment") - self.assertEqual(question_input['variables'][0]['required'], True) - self.assertEqual(question_input['variables'][0]['default'], "production") + assert len(question_input['variables']) == 1 + assert question_input['variables'][0]['name'] == "environment" + assert question_input['variables'][0]['required'] == True + assert question_input['variables'][0]['default'] == "production" # Check result - self.assertEqual(len(result['variables']), 1) + assert len(result['variables']) == 1 @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_multiple_queries(self, mock_execute): @@ -252,12 +249,12 @@ def test_create_question_multiple_queries(self, mock_execute): variables = mock_execute.call_args[1]['variables'] question_input = variables['question'] - self.assertEqual(len(question_input['queries']), 2) - self.assertEqual(question_input['queries'][0]['name'], "Query1") - self.assertEqual(question_input['queries'][1]['name'], "Query2") + assert len(question_input['queries']) == 2 + assert question_input['queries'][0]['name'] == "Query1" + assert question_input['queries'][1]['name'] == "Query2" # Check result - self.assertEqual(len(result['queries']), 2) + assert len(result['queries']) == 2 def test_create_question_validation_title_required(self): """Test validation that title is required""" @@ -321,8 +318,8 @@ def test_create_question_auto_naming(self, mock_execute): variables = mock_execute.call_args[1]['variables'] question_input = variables['question'] - self.assertEqual(question_input['queries'][0]['name'], "Query0") - self.assertEqual(question_input['queries'][1]['name'], "Query1") + assert question_input['queries'][0]['name'] == "Query0" + assert question_input['queries'][1]['name'] == "Query1" @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_results_are_default(self, mock_execute): @@ -348,7 +345,7 @@ def test_create_question_results_are_default(self, mock_execute): variables = mock_execute.call_args[1]['variables'] question_input = variables['question'] - self.assertEqual(question_input['queries'][0]['resultsAre'], "INFORMATIVE") + assert question_input['queries'][0]['resultsAre'] == "INFORMATIVE" @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_version_optional(self, mock_execute): @@ -375,7 +372,7 @@ def test_create_question_version_optional(self, mock_execute): question_input = variables['question'] # Version should not be in the query if not provided - self.assertNotIn('version', question_input['queries'][0]) + assert 'version' not in question_input['queries'][0] # Create question with version mock_execute.return_value = { @@ -398,7 +395,7 @@ def test_create_question_version_optional(self, mock_execute): question_input = variables['question'] # Version should be included when provided - self.assertEqual(question_input['queries'][0]['version'], "v1") + assert question_input['queries'][0]['version'] == "v1" @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_optional_fields_handling(self, mock_execute): @@ -428,12 +425,12 @@ def test_create_question_optional_fields_handling(self, mock_execute): question_input = variables['question'] # None values should not be included - self.assertNotIn('description', question_input) - self.assertNotIn('tags', question_input) + assert 'description' not in question_input + assert 'tags' not in question_input # Non-None values should be included - self.assertEqual(question_input['showTrend'], False) - self.assertEqual(question_input['pollingInterval'], "ONE_DAY") + assert question_input['showTrend'] == False + assert question_input['pollingInterval'] == "ONE_DAY" @patch('jupiterone.client.JupiterOneClient._execute_query') def test_create_question_integration_definition_id(self, mock_execute): @@ -460,7 +457,7 @@ def test_create_question_integration_definition_id(self, mock_execute): variables = mock_execute.call_args[1]['variables'] question_input = variables['question'] - self.assertEqual(question_input['integrationDefinitionId'], "integration-123") + assert question_input['integrationDefinitionId'] == "integration-123" # Check result - self.assertEqual(result['integrationDefinitionId'], "integration-123") + assert result['integrationDefinitionId'] == "integration-123" diff --git a/tests/test_question_management.py b/tests/test_question_management.py index 0c7ae0d..0b5ba9d 100644 --- a/tests/test_question_management.py +++ b/tests/test_question_management.py @@ -43,8 +43,8 @@ def test_update_question_title_only(self, mock_execute_query): # Check the mutation was called with correct parameters assert call_args[0][0] == UPDATE_QUESTION - # Check variables - variables = call_args[1]['variables'] + # Check variables - they are in the second positional argument + variables = call_args[0][1] assert variables['id'] == self.question_id assert variables['update']['title'] == "Updated Question Title" assert 'description' not in variables['update'] @@ -80,8 +80,8 @@ def test_update_question_description_only(self, mock_execute_query): mock_execute_query.assert_called_once() call_args = mock_execute_query.call_args - # Check variables - variables = call_args[1]['variables'] + # Check variables - they are in the second positional argument + variables = call_args[0][1] assert variables['id'] == self.question_id assert variables['update']['description'] == "Updated description" assert 'title' not in variables['update'] @@ -117,8 +117,8 @@ def test_update_question_tags_only(self, mock_execute_query): mock_execute_query.assert_called_once() call_args = mock_execute_query.call_args - # Check variables - variables = call_args[1]['variables'] + # Check variables - they are in the second positional argument + variables = call_args[0][1] assert variables['id'] == self.question_id assert variables['update']['tags'] == new_tags assert 'title' not in variables['update'] @@ -156,8 +156,8 @@ def test_update_question_queries_only(self, mock_execute_query): mock_execute_query.assert_called_once() call_args = mock_execute_query.call_args - # Check variables - variables = call_args[1]['variables'] + # Check variables - they are in the second positional argument + variables = call_args[0][1] assert variables['id'] == self.question_id assert variables['update']['queries'] == new_queries assert 'title' not in variables['update'] @@ -198,8 +198,8 @@ def test_update_question_comprehensive(self, mock_execute_query): mock_execute_query.assert_called_once() call_args = mock_execute_query.call_args - # Check variables - variables = call_args[1]['variables'] + # Check variables - they are in the second positional argument + variables = call_args[0][1] assert variables['id'] == self.question_id assert variables['update']['title'] == update_data['title'] assert variables['update']['description'] == update_data['description'] @@ -237,15 +237,15 @@ def test_update_question_with_kwargs(self, mock_execute_query): mock_execute_query.assert_called_once() call_args = mock_execute_query.call_args - # Check variables - variables = call_args[1]['variables'] + # Check variables - they are in the second positional argument + variables = call_args[0][1] assert variables['id'] == self.question_id assert variables['update']['showTrend'] is True assert variables['update']['pollingInterval'] == "ONE_HOUR" # Check result assert result['showTrend'] is True - assert result['update']['pollingInterval'] == "ONE_HOUR" + assert result['pollingInterval'] == "ONE_HOUR" @patch('jupiterone.client.JupiterOneClient._execute_query') def test_update_question_api_error(self, mock_execute_query): @@ -305,8 +305,8 @@ def test_delete_question_success(self, mock_execute_query): # Check the mutation was called with correct parameters assert call_args[0][0] == DELETE_QUESTION - # Check variables - variables = call_args[1]['variables'] + # Check variables - they are in the second positional argument + variables = call_args[0][1] assert variables['id'] == self.question_id # Check result From e65f6ac13d8032efd1afaf58f622088efe2bec76 Mon Sep 17 00:00:00 2001 From: SeaBlooms Date: Wed, 20 Aug 2025 09:58:23 -0600 Subject: [PATCH 22/22] add input validation for 'queries' in 'update_question()' method --- jupiterone/client.py | 26 +++++++- tests/test_question_management.py | 98 ++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/jupiterone/client.py b/jupiterone/client.py index 3dcc51e..2ce22d7 100644 --- a/jupiterone/client.py +++ b/jupiterone/client.py @@ -1518,7 +1518,31 @@ def update_question( if description is not None: update_data["description"] = description if queries is not None: - update_data["queries"] = queries + # Validate queries input using the same logic as create_question + if not isinstance(queries, list) or len(queries) == 0: + raise ValueError("queries must be a non-empty list") + + # Process each query to ensure required fields + processed_queries = [] + for idx, query in enumerate(queries): + if not isinstance(query, dict): + raise ValueError(f"Query at index {idx} must be a dictionary") + if "query" not in query: + raise ValueError(f"Query at index {idx} must have a 'query' field") + + processed_query = { + "query": query["query"], + "name": query.get("name", f"Query{idx}"), + "resultsAre": query.get("resultsAre", "INFORMATIVE") + } + + # Only add version if provided + if "version" in query: + processed_query["version"] = query["version"] + + processed_queries.append(processed_query) + + update_data["queries"] = processed_queries if tags is not None: update_data["tags"] = tags diff --git a/tests/test_question_management.py b/tests/test_question_management.py index 0b5ba9d..3512cff 100644 --- a/tests/test_question_management.py +++ b/tests/test_question_management.py @@ -159,7 +159,13 @@ def test_update_question_queries_only(self, mock_execute_query): # Check variables - they are in the second positional argument variables = call_args[0][1] assert variables['id'] == self.question_id - assert variables['update']['queries'] == new_queries + # The queries are now processed and enriched with default values + processed_queries = variables['update']['queries'] + assert len(processed_queries) == 1 + assert processed_queries[0]['name'] == "UpdatedQuery" + assert processed_queries[0]['query'] == "FIND User WITH active=true" + assert processed_queries[0]['version'] == "v2" + assert processed_queries[0]['resultsAre'] == "INFORMATIVE" # Default value added assert 'title' not in variables['update'] assert 'description' not in variables['update'] assert 'tags' not in variables['update'] @@ -204,7 +210,13 @@ def test_update_question_comprehensive(self, mock_execute_query): assert variables['update']['title'] == update_data['title'] assert variables['update']['description'] == update_data['description'] assert variables['update']['tags'] == update_data['tags'] - assert variables['update']['queries'] == update_data['queries'] + # The queries are now processed and enriched with default values + processed_queries = variables['update']['queries'] + assert len(processed_queries) == 1 + assert processed_queries[0]['name'] == "ComprehensiveQuery" + assert processed_queries[0]['query'] == "FIND * WITH _class='Host'" + assert processed_queries[0]['version'] == "v3" + assert processed_queries[0]['resultsAre'] == "INFORMATIVE" # Default value added # Check result assert result['title'] == update_data['title'] @@ -347,3 +359,85 @@ def test_delete_question_nonexistent_question(self, mock_execute_query): result = self.client.delete_question(question_id="nonexistent-id") assert result is None + + # Tests for queries validation in update_question + def test_update_question_queries_validation_empty_list(self): + """Test that update_question rejects empty queries list""" + with pytest.raises(ValueError, match="queries must be a non-empty list"): + self.client.update_question( + question_id=self.question_id, + queries=[] + ) + + def test_update_question_queries_validation_not_list(self): + """Test that update_question rejects non-list queries""" + with pytest.raises(ValueError, match="queries must be a non-empty list"): + self.client.update_question( + question_id=self.question_id, + queries="not a list" + ) + + def test_update_question_queries_validation_none(self): + """Test that update_question accepts None queries (no update)""" + # This should not raise an error since queries=None means no update + # The validation only happens when queries is provided + try: + self.client.update_question( + question_id=self.question_id, + title="Updated Title" + ) + except Exception as e: + # If it gets to the API call, that's fine - we're just testing validation + pass + + def test_update_question_queries_validation_missing_query_field(self): + """Test that update_question rejects queries missing 'query' field""" + with pytest.raises(ValueError, match="Query at index 0 must have a 'query' field"): + self.client.update_question( + question_id=self.question_id, + queries=[{"name": "InvalidQuery"}] + ) + + def test_update_question_queries_validation_invalid_query_type(self): + """Test that update_question rejects non-dict query items""" + with pytest.raises(ValueError, match="Query at index 0 must be a dictionary"): + self.client.update_question( + question_id=self.question_id, + queries=["not a dict"] + ) + + @patch('jupiterone.client.JupiterOneClient._execute_query') + def test_update_question_queries_validation_success(self, mock_execute_query): + """Test that update_question successfully processes valid queries""" + mock_response = { + "data": { + "updateQuestion": { + "id": self.question_id, + "title": "Updated Title", + "queries": [ + { + "name": "Query0", + "query": "FIND Host", + "resultsAre": "INFORMATIVE" + } + ] + } + } + } + mock_execute_query.return_value = mock_response + + result = self.client.update_question( + question_id=self.question_id, + queries=[{"query": "FIND Host"}] + ) + + # Verify the call was made with processed queries + call_args = mock_execute_query.call_args + variables = call_args[0][1] + assert variables['update']['queries'][0]['name'] == "Query0" + assert variables['update']['queries'][0]['query'] == "FIND Host" + assert variables['update']['queries'][0]['resultsAre'] == "INFORMATIVE" + + # Check result + assert result['id'] == self.question_id + assert result['title'] == "Updated Title"