NodeZero

Integrates with the NodeZero autonomous penetration testing platform to fetch weaknesses discovered during pentest operations. Automatically ingests HIGH and CRITICAL severity weaknesses as incidents for tracking and remediation.

Vulnerability Management · NodeZero

Details

IDNodeZero
ProviderHorizon3.ai
CategoryVulnerability Management
From Version6.1.0
Docker Imagedemisto/py3-tools:1.0.0.10895515
Supported ModulesAgentix XSIAM

README

NodeZero

Integrates with the NodeZero autonomous penetration testing platform to fetch weaknesses discovered during pentest operations. Automatically ingests HIGH and CRITICAL severity weaknesses as incidents for tracking and remediation.

Configure NodeZero on Cortex XSOAR

  1. Navigate to Settings > Integrations > Servers & Services.
  2. Search for NodeZero.
  3. Click Add instance to create and configure a new integration instance.
Parameter Description Required
Server URL The NodeZero API server URL. True
API Key The API Key required to authenticate to the NodeZero service. True
Trust any certificate (not secure) When selected, certificates are not checked. False
Use system proxy settings Runs the integration instance using the proxy server (HTTP or HTTPS) that you defined in the server configuration. False
Fetch incidents When selected, the integration fetches incidents. False
Incident type The incident type to create for fetched incidents. False
Maximum number of weaknesses to fetch Maximum number of incidents to fetch per run. Default is 200. False
First fetch time How far back to fetch on first run (e.g., “7 days”, “3 days”). Default is 7 days. False
Incidents Fetch Interval How often to fetch new incidents (in minutes). Default is 10080 (7 days). False
  1. Click Test to validate the URLs, token, and connection.

Fetch Incidents

The integration fetches HIGH and CRITICAL severity weaknesses from NodeZero pentest operations as incidents. Each weakness is converted to an XSOAR incident with the following mappings:

NodeZero Field XSOAR Incident Field
uuid dbotMirrorId
created_at occurred
severity severity (CRITICAL=4, HIGH=3)
vuln_id externalid
affected_asset_display_name sourcehostname
ip sourceip
score nodezeroweaknessscore
vuln_category nodezeroweaknesscategory
has_proof nodezeroweaknessproven
attack_paths_count nodezeroattackpathscount
vuln_cisa_kev nodezerocisakov
vuln_known_ransomware_campaign_use nodezeroransomwareuse
op_id nodezeroopid

Commands

You can execute these commands from the Cortex XSOAR CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message is displayed in the War Room with the command details.

nodezero-get-weaknesses

Retrieves HIGH and CRITICAL weaknesses discovered by NodeZero pentests.

Base Command

nodezero-get-weaknesses

Input

Argument Name Description Required
since_date Fetch weaknesses created on or after this date (ISO 8601, e.g. 2024-01-01T00:00:00). Defaults to 7 days ago. Optional
limit Maximum number of weaknesses to return (1–1000). Default is 50. Optional

Context Output

Path Type Description
NodeZero.Weakness.uuid String Unique identifier of the weakness.
NodeZero.Weakness.created_at Date Timestamp when the weakness was first discovered.
NodeZero.Weakness.vuln_id String Vulnerability identifier (e.g. CVE ID).
NodeZero.Weakness.vuln_name String Full vulnerability name.
NodeZero.Weakness.vuln_short_name String Short vulnerability name.
NodeZero.Weakness.vuln_category String Vulnerability category.
NodeZero.Weakness.vuln_cisa_kev Boolean Whether the vulnerability is in the CISA Known Exploited Vulnerabilities catalog.
NodeZero.Weakness.vuln_known_ransomware_campaign_use Boolean Whether the vulnerability is known to be used in ransomware campaigns.
NodeZero.Weakness.ip String IP address of the affected asset.
NodeZero.Weakness.has_proof Boolean Whether NodeZero has proof of exploitability.
NodeZero.Weakness.score Number Weakness severity score.
NodeZero.Weakness.severity String Weakness severity level (HIGH or CRITICAL).
NodeZero.Weakness.affected_asset_uuid String UUID of the affected asset.
NodeZero.Weakness.affected_asset_display_name String Display name of the affected asset.
NodeZero.Weakness.attack_paths_count Number Number of attack paths through this weakness.
NodeZero.Weakness.op_id String ID of the pentest operation that discovered this weakness.

Command example

!nodezero-get-weaknesses limit=5

Human Readable Output

NodeZero Weaknesses

uuid severity vuln_name ip score has_proof
abc-123 CRITICAL Example Vuln 10.0.0.1 9.8 true

Deduplication

The integration uses ID-based deduplication to prevent duplicate incidents:

  • On first run, weaknesses from the last N days (configured via “First fetch time”) are fetched.
  • On subsequent runs, the integration queries weaknesses since the most recent created_at timestamp from the previous fetch.
  • UUIDs of weaknesses at the latest timestamp are tracked to avoid re-fetching them if they appear in the next query window.

This ensures that even if multiple weaknesses share the same timestamp, they are only ingested once.

Known Limitations

  • Only HIGH and CRITICAL severity weaknesses are fetched.
  • The integration uses a GraphQL API with JWT-based authentication.
  • JWT tokens are cached and automatically refreshed before expiration.

Troubleshooting

If you encounter authentication errors, verify that:

  1. The API Key is correct and has not expired.
  2. The Server URL is accessible from the XSOAR server.
  3. SSL certificates are valid (or “Trust any certificate” is enabled for testing).

Configuration parameters

  • url — Server URL (required)
  • credentials — API Key (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetch — Fetch incidents
  • incidentType — Incident type
  • max_fetch — Maximum number of weaknesses to fetch
  • first_fetch — First fetch time
  • incidentFetchInterval — Incidents Fetch Interval

Commands (1)

  • nodezero-get-weaknesses

    Retrieves HIGH and CRITICAL weaknesses discovered by NodeZero pentests.

"""NodeZero Integration for Cortex XSOAR - Unit Tests file

Pytest Unit Tests for the NodeZero Integration.

More details: https://xsoar.pan.dev/docs/integrations/unit-testing
"""

import pytest
from unittest.mock import patch

from datetime import datetime, UTC

from NodeZero import (
    Client,
    Params,
    Weakness,
    LastRun,
    GetWeaknessesArgs,
    _test_module,
    fetch_incidents,
    fetch_all_weaknesses_pages,
    get_weaknesses_command,
    authenticate,
    dedup_by_ids,
    UnauthenticatedError,
    ResponseValidationError,
    DEFAULT_MAX_FETCH,
    DEFAULT_FIRST_FETCH,
)


# Test data fixtures
SAMPLE_WEAKNESS = {
    "uuid": "test-uuid-123",
    "created_at": "2024-01-15T10:30:00",
    "vuln_id": "CVE-2024-1234",
    "vuln_name": "Test Vulnerability Name",
    "vuln_short_name": "Test Vuln",
    "vuln_category": "Credential Access",
    "vuln_cisa_kev": True,
    "vuln_known_ransomware_campaign_use": False,
    "ip": "192.168.1.100",
    "has_proof": True,
    "score": 9.5,
    "severity": "CRITICAL",
    "affected_asset_uuid": "asset-uuid-456",
    "affected_asset_display_name": "test-server-01",
    "attack_paths_count": 3,
    "op_id": "op-123",
}

SAMPLE_PAGE_INFO = {
    "page_num": 1,
    "page_size": 100,
}

SAMPLE_WEAKNESSES_PAGE_RESPONSE = {
    "data": {
        "weaknesses_page": {
            "weaknesses": [SAMPLE_WEAKNESS],
            "page_info": SAMPLE_PAGE_INFO,
        }
    }
}

SAMPLE_AUTH_RESPONSE = {"token": "test-jwt-token-123"}

SAMPLE_HELLO_RESPONSE = {"data": {"hello": "world"}}


def create_test_client(base_url: str = "https://test.horizon3ai.com") -> Client:
    """Create a test client instance."""
    return Client(
        base_url=base_url,
        api_key="test-api-key",
        verify=False,
        proxy=False,
    )


class TestWeaknessModel:
    """Tests for the Weakness Pydantic model."""

    def test_weakness_parsing(self):
        """Test that a weakness is correctly parsed from API response."""
        weakness = Weakness.model_validate(SAMPLE_WEAKNESS)

        assert weakness.uuid == "test-uuid-123"
        assert weakness.severity == "CRITICAL"
        assert weakness.has_proof is True
        assert weakness.attack_paths_count == 3

    def test_weakness_to_incident(self):
        """Test that a weakness is correctly converted to an XSOAR incident."""
        weakness = Weakness.model_validate(SAMPLE_WEAKNESS)
        incident = weakness.to_incident()

        assert incident["name"] == "Test Vuln on test-server-01"
        assert incident["occurred"] == "2024-01-15T10:30:00Z"
        assert incident["dbotMirrorId"] == "test-uuid-123"
        assert "rawJSON" in incident

    def test_weakness_to_incident_with_missing_optional_fields(self):
        """Test incident conversion when optional fields are None."""
        weakness_data = {
            "uuid": "test-uuid",
            "created_at": "2024-01-15T10:30:00",
            "vuln_id": "CVE-2024-0001",
            "vuln_name": None,
            "vuln_short_name": None,
            "vuln_category": None,
            "vuln_cisa_kev": None,
            "vuln_known_ransomware_campaign_use": None,
            "ip": None,
            "has_proof": None,
            "score": None,
            "severity": None,
            "affected_asset_uuid": None,
            "affected_asset_display_name": None,
            "attack_paths_count": 0,
            "op_id": None,
        }
        weakness = Weakness.model_validate(weakness_data)
        incident = weakness.to_incident()

        # Should fall back to vuln_id when vuln_short_name and vuln_name are None
        assert incident["name"] == "CVE-2024-0001 on Unknown Asset"


class TestParamsModel:
    """Tests for the Params Pydantic model."""

    def test_params_parsing_with_defaults(self):
        """Test that params are parsed with default values."""
        params_data = {
            "url": "https://portal.horizon3ai.com",
            "credentials": {"password": "test-key"},
        }
        params = Params.model_validate(params_data)

        assert params.url == "https://portal.horizon3ai.com"
        assert params.credentials.password == "test-key"
        assert params.insecure is False
        assert params.proxy is False
        assert params.max_fetch == DEFAULT_MAX_FETCH
        assert params.first_fetch == DEFAULT_FIRST_FETCH

    def test_params_parsing_with_custom_values(self):
        """Test that params are parsed with custom values."""
        params_data = {
            "url": "https://custom.horizon3ai.com",
            "credentials": {"password": "custom-key"},
            "insecure": True,
            "proxy": True,
            "max_fetch": 50,
            "first_fetch": "14 days",
        }
        params = Params.model_validate(params_data)

        assert params.max_fetch == 50
        assert params.first_fetch == "14 days"
        assert params.insecure is True


class TestClientAuthentication:
    """Tests for Client authentication functionality."""

    def test_load_integration_context_restores_token(self):
        """Test that client loads JWT and expiry from integration context."""
        import time

        stored_jwt = "stored-jwt-token"
        stored_expiry = int(time.time()) + 3600

        with patch("NodeZero.get_integration_context") as mock_get_context:
            mock_get_context.return_value = {"jwt": stored_jwt, "expiry": stored_expiry}
            client = create_test_client()

        assert client._jwt == stored_jwt
        assert client._expiry == stored_expiry
        assert client.is_authenticated() is True

    def test_load_integration_context_handles_empty_context(self):
        """Test that client handles empty integration context."""
        with patch("NodeZero.get_integration_context") as mock_get_context:
            mock_get_context.return_value = {}
            client = create_test_client()

        assert client._jwt is None
        assert client._expiry == 0
        assert client.is_authenticated() is False

    def test_is_authenticated_when_no_token(self):
        """Test is_authenticated returns False when no token exists."""
        client = create_test_client()
        client._jwt = None
        client._expiry = 0

        assert client.is_authenticated() is False

    def test_is_authenticated_when_token_expired(self):
        """Test is_authenticated returns False when token is expired."""
        client = create_test_client()
        client._jwt = "some-token"
        client._expiry = 0  # Expired

        assert client.is_authenticated() is False

    def test_is_authenticated_when_valid_token(self):
        """Test is_authenticated returns True when token is valid."""
        import time

        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = int(time.time()) + 3600  # 1 hour from now

        assert client.is_authenticated() is True

    def test_authenticate_success(self, requests_mock):
        """Test successful authentication."""
        client = create_test_client()
        requests_mock.post(
            "https://test.horizon3ai.com/v1/auth",
            json=SAMPLE_AUTH_RESPONSE,
        )

        with patch("NodeZero.set_integration_context"):
            client.authenticate()

        assert client._jwt == "test-jwt-token-123"
        assert client._expiry > 0

    def test_authenticate_401_error(self, requests_mock):
        """Test authentication fails with 401."""
        client = create_test_client()
        requests_mock.post(
            "https://test.horizon3ai.com/v1/auth",
            status_code=401,
        )

        with pytest.raises(UnauthenticatedError):
            client.authenticate()


class TestClientGraphQL:
    """Tests for Client GraphQL functionality."""

    def test_hello_world_success(self, requests_mock):
        """Test hello_world query succeeds."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_HELLO_RESPONSE,
        )

        # Should not raise
        client.hello_world()

    def test_graphql_401_error(self, requests_mock):
        """Test GraphQL request fails with 401."""
        client = create_test_client()
        client._jwt = "invalid-token"
        client._expiry = 9999999999

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            status_code=401,
        )

        with pytest.raises(UnauthenticatedError):
            client.hello_world()

    def test_query_weaknesses_page_invalid_response(self, requests_mock):
        """Test that invalid API response raises ResponseValidationError."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        # Missing required fields in the response
        invalid_response = {
            "data": {
                "weaknesses_page": {
                    "weaknesses": [{"uuid": "test-uuid"}],  # Missing required fields
                    "page_info": {
                        "page_num": 1,
                        "page_size": 100,
                    },
                }
            }
        }

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=invalid_response,
        )

        with pytest.raises(ResponseValidationError):
            client.query_weaknesses_page(page_num=1, page_size=100, since_date="2024-01-01")


class TestTestModule:
    """Tests for the test_module command."""

    def test_test_module_success(self, requests_mock):
        """Test test_module succeeds with valid credentials."""
        client = create_test_client()

        requests_mock.post(
            "https://test.horizon3ai.com/v1/auth",
            json=SAMPLE_AUTH_RESPONSE,
        )
        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_HELLO_RESPONSE,
        )

        with patch("NodeZero.set_integration_context"):
            result = _test_module(client)

        assert result == "ok"


class TestFetchIncidents:
    """Tests for the fetch_incidents command."""

    @patch("NodeZero.demisto")
    def test_fetch_incidents_first_run(self, mock_demisto, requests_mock):
        """Test fetch_incidents on first run (no last_run state)."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        mock_demisto.getLastRun.return_value = {}

        # Mock the weaknesses page response
        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_WEAKNESSES_PAGE_RESPONSE,
        )

        first_fetch_time = datetime(2024, 1, 8, 0, 0, 0, tzinfo=UTC)
        incidents, next_run = fetch_incidents(client, max_fetch=100, first_fetch_time=first_fetch_time)

        assert len(incidents) == 1
        assert incidents[0]["name"] == "Test Vuln on test-server-01"
        # Verify deduplication state is set
        assert next_run.last_fetch_date == "2024-01-15T10:30:00"
        assert next_run.last_ids == ["test-uuid-123"]

    @patch("NodeZero.demisto")
    def test_fetch_incidents_subsequent_run(self, mock_demisto, requests_mock):
        """Test fetch_incidents with existing last_run state."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        mock_demisto.getLastRun.return_value = {"last_fetch_date": "2024-01-10T00:00:00", "last_ids": []}

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_WEAKNESSES_PAGE_RESPONSE,
        )

        first_fetch_time = datetime(2024, 1, 8, 0, 0, 0, tzinfo=UTC)
        incidents, next_run = fetch_incidents(client, max_fetch=100, first_fetch_time=first_fetch_time)

        assert len(incidents) == 1
        assert next_run.last_fetch_date == "2024-01-15T10:30:00"
        assert next_run.last_ids == ["test-uuid-123"]

    @patch("NodeZero.demisto")
    def test_fetch_incidents_deduplicates_by_id(self, mock_demisto, requests_mock):
        """Test fetch_incidents filters out previously fetched incidents."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        # Simulate a previous run that already fetched this weakness
        mock_demisto.getLastRun.return_value = {
            "last_fetch_date": "2024-01-15T10:30:00",
            "last_ids": ["test-uuid-123"],
        }

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_WEAKNESSES_PAGE_RESPONSE,
        )

        first_fetch_time = datetime(2024, 1, 8, 0, 0, 0, tzinfo=UTC)
        incidents, next_run = fetch_incidents(client, max_fetch=100, first_fetch_time=first_fetch_time)

        # Should return no incidents since the only one was already fetched
        assert len(incidents) == 0
        # Timestamp should advance by 1s to prevent infinite loop; last_ids cleared
        assert next_run.last_fetch_date == "2024-01-15T10:30:01"
        assert next_run.last_ids == []

    @patch("NodeZero.demisto")
    def test_fetch_incidents_tracks_ids_at_same_timestamp(self, mock_demisto, requests_mock):
        """Test that last_ids includes all UUIDs at the latest timestamp."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        mock_demisto.getLastRun.return_value = {}

        # Create response with multiple weaknesses at same timestamp
        same_timestamp = "2024-01-15T10:30:00"
        multi_weakness_response = {
            "data": {
                "weaknesses_page": {
                    "weaknesses": [
                        {**SAMPLE_WEAKNESS, "uuid": "uuid-1", "created_at": same_timestamp},
                        {**SAMPLE_WEAKNESS, "uuid": "uuid-2", "created_at": same_timestamp},
                        {**SAMPLE_WEAKNESS, "uuid": "uuid-3", "created_at": "2024-01-14T10:30:00"},  # older
                    ],
                    "page_info": SAMPLE_PAGE_INFO,
                }
            }
        }

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=multi_weakness_response,
        )

        first_fetch_time = datetime(2024, 1, 8, 0, 0, 0, tzinfo=UTC)
        incidents, next_run = fetch_incidents(client, max_fetch=100, first_fetch_time=first_fetch_time)

        assert len(incidents) == 3
        # Only IDs at the latest timestamp should be in last_ids
        assert next_run.last_fetch_date == same_timestamp
        assert set(next_run.last_ids) == {"uuid-1", "uuid-2"}


class TestFetchAllWeaknessesPages:
    """Tests for pagination in fetch_all_weaknesses_pages."""

    def test_fetch_single_page(self, requests_mock):
        """Test fetching a single page of weaknesses."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_WEAKNESSES_PAGE_RESPONSE,
        )

        weaknesses = fetch_all_weaknesses_pages(client, since_date="2024-01-01T00:00:00", max_fetch=100)

        assert len(weaknesses) == 1
        assert weaknesses[0].uuid == "test-uuid-123"

    def test_fetch_respects_max_fetch(self, requests_mock):
        """Test that max_fetch limits the number of results."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        # Create response with multiple weaknesses
        multi_weakness_response = {
            "data": {
                "weaknesses_page": {
                    "weaknesses": [{**SAMPLE_WEAKNESS, "uuid": f"uuid-{i}"} for i in range(10)],
                    "page_info": {
                        "page_num": 1,
                        "page_size": 100,
                    },
                }
            }
        }

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=multi_weakness_response,
        )

        weaknesses = fetch_all_weaknesses_pages(client, since_date="2024-01-01T00:00:00", max_fetch=5)

        assert len(weaknesses) == 5

    def test_fetch_multiple_pages(self, requests_mock):
        """Test fetching multiple pages when first page is full."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        # First page returns exactly page_size (100) items, triggering next page fetch
        page1_response = {
            "data": {
                "weaknesses_page": {
                    "weaknesses": [{**SAMPLE_WEAKNESS, "uuid": f"page1-uuid-{i}"} for i in range(100)],
                    "page_info": {
                        "page_num": 1,
                        "page_size": 100,
                    },
                }
            }
        }

        # Second page returns fewer than page_size, indicating last page
        page2_response = {
            "data": {
                "weaknesses_page": {
                    "weaknesses": [{**SAMPLE_WEAKNESS, "uuid": f"page2-uuid-{i}"} for i in range(50)],
                    "page_info": {
                        "page_num": 2,
                        "page_size": 100,
                    },
                }
            }
        }

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            [{"json": page1_response}, {"json": page2_response}],
        )

        weaknesses = fetch_all_weaknesses_pages(client, since_date="2024-01-01T00:00:00", max_fetch=200)

        assert len(weaknesses) == 150
        # Verify we got items from both pages
        assert weaknesses[0].uuid == "page1-uuid-0"
        assert weaknesses[100].uuid == "page2-uuid-0"


class TestLastRunModel:
    """Tests for the LastRun state model."""

    def test_last_run_with_date(self):
        """Test LastRun model with a date value."""
        last_run = LastRun(last_fetch_date="2024-01-15T10:30:00")
        assert last_run.last_fetch_date == "2024-01-15T10:30:00"
        assert last_run.last_ids == []

    def test_last_run_with_ids(self):
        """Test LastRun model with last_ids."""
        last_run = LastRun(last_fetch_date="2024-01-15T10:30:00", last_ids=["uuid-1", "uuid-2"])
        assert last_run.last_fetch_date == "2024-01-15T10:30:00"
        assert last_run.last_ids == ["uuid-1", "uuid-2"]

    def test_last_run_empty(self):
        """Test LastRun model with no date (first run)."""
        last_run = LastRun.model_validate({})
        assert last_run.last_fetch_date is None
        assert last_run.last_ids == []


class TestDedupByIds:
    """Tests for the dedup_by_ids helper function."""

    def test_dedup_filters_matching_ids(self):
        """Test that weaknesses with matching IDs are filtered out."""
        weaknesses = [
            Weakness.model_validate({**SAMPLE_WEAKNESS, "uuid": "uuid-1"}),
            Weakness.model_validate({**SAMPLE_WEAKNESS, "uuid": "uuid-2"}),
            Weakness.model_validate({**SAMPLE_WEAKNESS, "uuid": "uuid-3"}),
        ]

        result = dedup_by_ids(weaknesses, ["uuid-1", "uuid-3"])

        assert len(result) == 1
        assert result[0].uuid == "uuid-2"

    def test_dedup_empty_skip_list(self):
        """Test that empty skip list returns all weaknesses."""
        weaknesses = [
            Weakness.model_validate({**SAMPLE_WEAKNESS, "uuid": "uuid-1"}),
            Weakness.model_validate({**SAMPLE_WEAKNESS, "uuid": "uuid-2"}),
        ]

        result = dedup_by_ids(weaknesses, [])

        assert len(result) == 2

    def test_dedup_all_filtered(self):
        """Test that all weaknesses can be filtered out."""
        weaknesses = [
            Weakness.model_validate({**SAMPLE_WEAKNESS, "uuid": "uuid-1"}),
        ]

        result = dedup_by_ids(weaknesses, ["uuid-1"])

        assert len(result) == 0

    def test_dedup_empty_weaknesses(self):
        """Test with empty weaknesses list."""
        result = dedup_by_ids([], ["uuid-1", "uuid-2"])

        assert len(result) == 0


class TestAuthenticateHelper:
    """Tests for the authenticate helper function."""

    def test_authenticate_when_not_authenticated(self, requests_mock):
        """Test authenticate() calls client.authenticate() when not authenticated."""
        client = create_test_client()
        client._jwt = None
        client._expiry = 0

        requests_mock.post(
            "https://test.horizon3ai.com/v1/auth",
            json=SAMPLE_AUTH_RESPONSE,
        )

        with patch("NodeZero.set_integration_context"):
            authenticate(client)

        assert client._jwt == "test-jwt-token-123"

    def test_authenticate_skips_when_authenticated(self):
        """Test authenticate() skips auth when already authenticated."""
        import time

        client = create_test_client()
        client._jwt = "existing-token"
        client._expiry = int(time.time()) + 3600

        # Should not make any requests
        authenticate(client)

        assert client._jwt == "existing-token"


class TestGetWeaknessesCommand:
    """Tests for the nodezero-get-weaknesses command."""

    def test_get_weaknesses_returns_results(self, requests_mock):
        """Test that the command returns weaknesses as CommandResults."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_WEAKNESSES_PAGE_RESPONSE,
        )

        args = GetWeaknessesArgs(since_date="2024-01-01T00:00:00", limit=10)
        result = get_weaknesses_command(client, args)

        assert result.outputs_prefix == "NodeZero.Weakness"
        assert result.outputs_key_field == "uuid"
        assert len(result.outputs) == 1
        assert result.outputs[0]["uuid"] == "test-uuid-123"
        assert "NodeZero Weaknesses" in result.readable_output

    def test_get_weaknesses_default_args(self, requests_mock):
        """Test that the command works with default arguments."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_WEAKNESSES_PAGE_RESPONSE,
        )

        result = get_weaknesses_command(client, GetWeaknessesArgs())

        assert result.outputs_prefix == "NodeZero.Weakness"
        assert len(result.outputs) == 1

    def test_get_weaknesses_empty_results(self, requests_mock):
        """Test that the command handles empty results."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        empty_response = {
            "data": {
                "weaknesses_page": {
                    "weaknesses": [],
                    "page_info": {"page_num": 1, "page_size": 50},
                }
            }
        }
        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=empty_response,
        )

        args = GetWeaknessesArgs(since_date="2024-01-01T00:00:00")
        result = get_weaknesses_command(client, args)

        assert result.outputs == []

    def test_get_weaknesses_limit_capped(self, requests_mock):
        """Test that limit is capped at 1000."""
        client = create_test_client()
        client._jwt = "valid-token"
        client._expiry = 9999999999

        requests_mock.post(
            "https://test.horizon3ai.com/v1/graphql",
            json=SAMPLE_WEAKNESSES_PAGE_RESPONSE,
        )

        # pydantic le=1000 constraint should clamp/reject values > 1000
        args = GetWeaknessesArgs.model_validate({"since_date": "2024-01-01T00:00:00", "limit": "1000"})
        result = get_weaknesses_command(client, args)

        assert result.outputs_prefix == "NodeZero.Weakness"