',
+ markdown_content
+ )
+
+ md = markdown.Markdown(extensions=[
+ 'markdown.extensions.fenced_code',
+ 'markdown.extensions.tables',
+ 'markdown.extensions.toc',
+ 'markdown.extensions.md_in_html',
+ ])
+ html_content = md.convert(markdown_content)
+
+ # Apply transformations
+ html_content = rewrite_image_paths(html_content)
+ html_content = convert_github_alerts(html_content)
+ html_content = rewrite_doc_links(html_content)
+
+ # Extract title from path
+ title = doc_path.split('/')[-1].replace('-', ' ').replace('_', ' ').title()
+ except Exception as e:
+ html_content = f"Error rendering documentation: {e}
"
+ title = "Error"
+
+ # Build navigation tree
+ nav_tree = build_nav_tree(DOCS_DIR)
+
+ # Add CHANGELOG as the last item
+ nav_tree.append({
+ 'title': 'Changelog',
+ 'url': '/Documentation/changelog/',
+ 'children': [],
+ 'is_folder': False
+ })
+
+ context = {
+ 'content': html_content,
+ 'title': title,
+ 'nav_tree': nav_tree,
+ }
+
+ return render(request, 'documentation.html', context)
diff --git a/LogstashUI/Management/migrations/__init__.py b/src/logstashui/LogstashUI/__init__.py
similarity index 100%
rename from LogstashUI/Management/migrations/__init__.py
rename to src/logstashui/LogstashUI/__init__.py
diff --git a/LogstashUI/LogstashUI/asgi.py b/src/logstashui/LogstashUI/asgi.py
similarity index 90%
rename from LogstashUI/LogstashUI/asgi.py
rename to src/logstashui/LogstashUI/asgi.py
index d9af582e..45fa77ae 100644
--- a/LogstashUI/LogstashUI/asgi.py
+++ b/src/logstashui/LogstashUI/asgi.py
@@ -1,5 +1,5 @@
"""
-ASGI config for LogstashUI project.
+ASGI config for logstashui project.
It exposes the ASGI callable as a module-level variable named ``application``.
diff --git a/LogstashUI/LogstashUI/config.py b/src/logstashui/LogstashUI/config.py
similarity index 81%
rename from LogstashUI/LogstashUI/config.py
rename to src/logstashui/LogstashUI/config.py
index 3fe882a0..4e79e595 100644
--- a/LogstashUI/LogstashUI/config.py
+++ b/src/logstashui/LogstashUI/config.py
@@ -18,6 +18,9 @@
"logstash_settings": "/etc/logstash",
"logstash_log_path": "/var/log/logstash"
}
+ },
+ "no_auth": {
+ "enabled": False
}
}
@@ -48,7 +51,7 @@ def deep_merge(base: dict, override: dict) -> dict:
def load_config() -> dict:
"""
- Load LogstashUI configuration from YAML file specified in LOGSTASHUI_CONFIG env var.
+ Load logstashui configuration from YAML file specified in LOGSTASHUI_CONFIG env var.
Falls back to DEFAULT_CONFIG if env var is not set or file doesn't exist.
Returns:
@@ -57,11 +60,17 @@ def load_config() -> dict:
config = DEFAULT_CONFIG.copy()
config_path = os.environ.get('LOGSTASHUI_CONFIG')
-
+
if not config_path:
- logger.info("LOGSTASHUI_CONFIG environment variable not set, using default configuration")
- return config
-
+ # Fall back to logstashui.yml adjacent to the project directory
+ default_config_path = Path(__file__).resolve().parent.parent / 'logstashui.yml'
+ if default_config_path.exists():
+ logger.info(f"LOGSTASHUI_CONFIG not set, using default path: {default_config_path}")
+ config_path = str(default_config_path)
+ else:
+ logger.info("LOGSTASHUI_CONFIG not set and no logstashui.yml found, using default configuration")
+ return config
+
config_file = Path(config_path)
if not config_file.exists():
diff --git a/LogstashUI/LogstashUI/settings.py b/src/logstashui/LogstashUI/settings.py
similarity index 83%
rename from LogstashUI/LogstashUI/settings.py
rename to src/logstashui/LogstashUI/settings.py
index 9a92eaae..ab16ec45 100644
--- a/LogstashUI/LogstashUI/settings.py
+++ b/src/logstashui/LogstashUI/settings.py
@@ -1,5 +1,5 @@
"""
-Django settings for LogstashUI project.
+Django settings for logstashui project.
Generated by 'django-admin startproject' using Django 5.2.6.
@@ -12,13 +12,17 @@
from pathlib import Path
import os, platform
+from importlib.metadata import version, PackageNotFoundError
from Common.encryption import get_django_secret_key
from .config import CONFIG
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
-# LogstashUI Runtime Configuration
+# Project root is 2 levels up from BASE_DIR (src/logstashui/)
+PROJECT_ROOT = BASE_DIR.parent.parent
+
+# logstashui Runtime Configuration
# Loaded from YAML file specified in LOGSTASHUI_CONFIG environment variable
# Falls back to DEFAULT_CONFIG if not specified
LOGSTASHUI_CONFIG = CONFIG
@@ -39,7 +43,25 @@
# Example: ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
-__VERSION__ = "0.3.5"
+def _get_version():
+ """Get version from installed package metadata or pyproject.toml"""
+ try:
+ return version("LogstashUI")
+ except PackageNotFoundError:
+ try:
+ import tomllib
+ pyproject_path = PROJECT_ROOT / "pyproject.toml"
+ if pyproject_path.exists():
+ with open(pyproject_path, "rb") as f:
+ pyproject_data = tomllib.load(f)
+ return pyproject_data.get("project", {}).get("version", "0.0.0+unknown")
+ except Exception:
+ pass
+ return "0.0.0+unknown"
+
+__VERSION__ = _get_version()
+__PREFERRED_LS_AGENT_VERSION__ = "0.2.6"
+
# Application definition
INSTALLED_APPS = [
@@ -51,13 +73,14 @@
'django.contrib.messages',
'django.contrib.staticfiles',
- # Apps of LogstashUI
+ # Apps of logstashui
'PipelineManager',
'Management',
'Utilities',
'SNMP',
'Monitoring',
'Site',
+ 'Documentation',
# Frameworks
'django_htmx',
@@ -163,7 +186,8 @@
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [
- BASE_DIR / "Site/static"
+ BASE_DIR / "Site/static",
+ PROJECT_ROOT / "docs" / "images",
]
if platform.system() == "Windows":
@@ -179,6 +203,17 @@
]
+NO_AUTH_MODE = LOGSTASHUI_CONFIG.get('no_auth', {}).get('enabled', False)
+
+if NO_AUTH_MODE:
+ import logging
+ logging.getLogger(__name__).warning(
+ "*** NO_AUTH MODE IS ENABLED — All authentication is bypassed. "
+ "Do not use in production! ***"
+ )
+ _auth_idx = MIDDLEWARE.index('django.contrib.auth.middleware.AuthenticationMiddleware')
+ MIDDLEWARE.insert(_auth_idx + 1, 'Common.middleware.NoAuthMiddleware')
+
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/Management/Login/"
LOGIN_URL = "/Management/Login/"
@@ -189,7 +224,13 @@
"/static/",
"/health/",
"/ConnectionManager/StreamSimulate/",
- "/ConnectionManager/StreamSimulate"
+ "/ConnectionManager/StreamSimulate",
+ "/ConnectionManager/Enroll/",
+ "/ConnectionManager/Enroll",
+ "/ConnectionManager/CheckIn/",
+ "/ConnectionManager/CheckIn",
+ "/ConnectionManager/GetConfigChanges/",
+ "/ConnectionManager/GetConfigChanges"
]
# Session Configuration
@@ -251,8 +292,8 @@
SECURE_SSL_REDIRECT = False
X_FRAME_OPTIONS = 'SAMEORIGIN'
-# LogstashAgent Configuration
-# URL for the LogstashAgent API
+# logstashagent Configuration
+# URL for the logstashagent API
# Can be overridden with LOGSTASH_AGENT_URL environment variable
#
# Routing based on simulation mode:
@@ -298,7 +339,7 @@
},
'file': {
'level': 'INFO',
- 'class': 'logging.handlers.RotatingFileHandler',
+ 'class': 'concurrent_log_handler.ConcurrentRotatingFileHandler',
'filename': LOGS_DIR / 'logstashui.log',
'maxBytes': 1024 * 1024 * 10, # 10 MB
'backupCount': 5,
diff --git a/LogstashUI/LogstashUI/urls.py b/src/logstashui/LogstashUI/urls.py
similarity index 93%
rename from LogstashUI/LogstashUI/urls.py
rename to src/logstashui/LogstashUI/urls.py
index bd0f4219..a38cff48 100644
--- a/LogstashUI/LogstashUI/urls.py
+++ b/src/logstashui/LogstashUI/urls.py
@@ -3,7 +3,7 @@
#you may not use this file except in compliance with the Elastic License.
"""
-URL configuration for LogstashUI project.
+URL configuration for logstashui project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
@@ -18,11 +18,11 @@
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
-from django.contrib import admin
+
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
-from django.contrib.auth import views as auth_views
+
# Custom error handlers
handler400 = 'Common.error_handlers.handler400'
@@ -40,6 +40,7 @@ def crash(request):
path('SNMP/', include('SNMP.urls')),
path('Monitoring/', include('Monitoring.urls')),
path('', include('Site.urls')),
+ path('Documentation/', include("Documentation.urls")),
#path('Crash', crash)
]
diff --git a/LogstashUI/LogstashUI/wsgi.py b/src/logstashui/LogstashUI/wsgi.py
similarity index 90%
rename from LogstashUI/LogstashUI/wsgi.py
rename to src/logstashui/LogstashUI/wsgi.py
index c939c39e..d43ae1ef 100644
--- a/LogstashUI/LogstashUI/wsgi.py
+++ b/src/logstashui/LogstashUI/wsgi.py
@@ -1,5 +1,5 @@
"""
-WSGI config for LogstashUI project.
+WSGI config for logstashui project.
It exposes the WSGI callable as a module-level variable named ``application``.
diff --git a/LogstashUI/Monitoring/__init__.py b/src/logstashui/Management/__init__.py
similarity index 100%
rename from LogstashUI/Monitoring/__init__.py
rename to src/logstashui/Management/__init__.py
diff --git a/LogstashUI/Management/apps.py b/src/logstashui/Management/apps.py
similarity index 100%
rename from LogstashUI/Management/apps.py
rename to src/logstashui/Management/apps.py
diff --git a/LogstashUI/Management/migrations/0001_initial.py b/src/logstashui/Management/migrations/0001_initial.py
similarity index 100%
rename from LogstashUI/Management/migrations/0001_initial.py
rename to src/logstashui/Management/migrations/0001_initial.py
diff --git a/LogstashUI/Monitoring/migrations/__init__.py b/src/logstashui/Management/migrations/__init__.py
similarity index 100%
rename from LogstashUI/Monitoring/migrations/__init__.py
rename to src/logstashui/Management/migrations/__init__.py
diff --git a/LogstashUI/Management/models.py b/src/logstashui/Management/models.py
similarity index 100%
rename from LogstashUI/Management/models.py
rename to src/logstashui/Management/models.py
diff --git a/LogstashUI/Management/templates/components/user_row.html b/src/logstashui/Management/templates/components/user_row.html
similarity index 100%
rename from LogstashUI/Management/templates/components/user_row.html
rename to src/logstashui/Management/templates/components/user_row.html
diff --git a/LogstashUI/Management/templates/logs.html b/src/logstashui/Management/templates/logs.html
similarity index 100%
rename from LogstashUI/Management/templates/logs.html
rename to src/logstashui/Management/templates/logs.html
diff --git a/LogstashUI/Management/templates/management.html b/src/logstashui/Management/templates/management.html
similarity index 100%
rename from LogstashUI/Management/templates/management.html
rename to src/logstashui/Management/templates/management.html
diff --git a/LogstashUI/Management/templates/registration/login.html b/src/logstashui/Management/templates/registration/login.html
similarity index 92%
rename from LogstashUI/Management/templates/registration/login.html
rename to src/logstashui/Management/templates/registration/login.html
index 73a9dc7c..116d26ed 100644
--- a/LogstashUI/Management/templates/registration/login.html
+++ b/src/logstashui/Management/templates/registration/login.html
@@ -11,6 +11,7 @@
Login - Logstash UI
+
{% tailwind_css %}
+
+
+
+
+
+
+
+
Get Started with Agent Policies
+
Create your first policy to manage Logstash agent configurations
+
+
+
+
+
+ Add Your First Policy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + Add Policy
+
+
+
+
+ Agent Policy
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Unsaved Changes
+
+
+
+
+
+
+ Save
+
+
+
+
+
+ Deploy
+
+
+
+
+
+
+
+
+
+
+
Managed Instances
+
—
+
+
+
+
+
+
+
Undeployed Changes
+
—
+
+
+
+
+
+
+
+
+
+
+
+
+ Policy Config
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
log4j2.properties
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enrollment Tokens
+
+
+
+
+
+
Mode:
+
+ Form
+ YML
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Default
+
+
+
+
+
+
+
+ Centralized
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Documentation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Unsafe Shutdown Enabled
+
This could result in data loss. Click here to set it back to the default (false).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Allow Superuser Enabled
+
We don't recommend using this setting for security purposes. Click to set back to the default (false).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Config Debug Enabled
+
This prints your fully compiled pipeline configuration into the logs as a debug log message. We don't recommend enabling this. Click to set it back to default (false).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Batch Delay Modified
+
It's typically not a good idea to change this setting. Click to set it to the default.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Warning
+
These pipelines will not be applied because this policy uses Centralized Pipeline Management. We will still push the pipelines to the node, but Logstash will not load them. To edit pipelines for this policy, add the Elasticsearch instance as a Connection in the Connection Manager
+
+
+
+
+ {% include 'components/pipeline_manager/pipeline_list_content.html' %}
+
+
+
+
+
+
+
+
+
+
+
+
A keystore password must be set before managing keystore entries.
+
+
+ Set Keystore Password
+
+
+
+
+
+
+
+
+
+ Create Key
+
+
+
+ Change Keystore Password
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No results found
+
Try adjusting your search terms
+
+
+
+
+
+
+
+ Variable
+ Value
+ Updated At
+ Actions
+
+
+
+
+
+
+
+
+
+
+
No keystore entries found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Name
+ Token
+ Actions
+
+
+
+
+
+
+
+
+
+
+
No enrollment tokens found
+
Tokens will be created automatically when you save this policy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Default Logstash Configuration Guide
+
Quick setup for standard Logstash configurations
+
+
+
+
+
+
+
+
+
+
+ {% include 'components/pipeline_manager/logstashyml_guides/default.html' %}
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
+
Centralized Pipeline Management Guide
+
Configure Logstash for centralized pipeline management
+
+
+
+
+
+
+
+
+
+
+ {% include 'components/pipeline_manager/logstashyml_guides/centralized_pipeline_management.html' %}
+
+
+
+
+
+ Close
+
+
+
+
+
+
+{% include 'popup.html' %}
+
+
+{% include 'components/pipeline_manager/policy_deploy_modal.html' %}
+
+
+{% include 'components/pipeline_manager/keystore_modal.html' %}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/change_policy_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/change_policy_modal.html
new file mode 100644
index 00000000..62186a5e
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/change_policy_modal.html
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
Change Policy
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Changing your policy will result in restarting your instance of Logstash.
+
+
Select the policy to assign to this agent.
+
+
+
+
+
+
+ Cancel
+
+
+ Change Policy
+
+
+
+
+
+
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/collapsible_row.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/collapsible_row.html
new file mode 100644
index 00000000..8f053c78
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/collapsible_row.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ {% include 'components/pipeline_manager/pipeline_list_content.html' %}
+
\ No newline at end of file
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/connection_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/connection_modal.html
new file mode 100644
index 00000000..d2d327ad
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/connection_modal.html
@@ -0,0 +1,1008 @@
+
+
+
+
+
+
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/keystore_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/keystore_modal.html
new file mode 100644
index 00000000..4864bf15
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/keystore_modal.html
@@ -0,0 +1,269 @@
+
+
+
+
+
+
+
+
+
Create Keystore Entry
+
+
+
+
+
+
+
+
+
+
+
+ Key Name
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Create
+
+
+
+
+
+
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/centralized_pipeline_management.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/centralized_pipeline_management.html
new file mode 100644
index 00000000..9359d5c8
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/centralized_pipeline_management.html
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+
+
+
+
About Centralized Pipeline Management
+
Configure Logstash to retrieve pipeline configurations from Elasticsearch, enabling centralized management of your pipelines.
+
+
+
+
+
+
+
+
+
+
+
+
Important Notice
+
When Centralized Pipeline Management is enabled, pipeline definitions are no longer managed through Agent Policy. Pipelines will be controlled by Centralized Pipeline Management instead. LogstashUI will continue managing logstash.yml, jvm.options, log4j2.properties, and keystore settings for Logstash instances enrolled with LogstashAgent.
+
+
+
+
+
+
+
+
+
+
+
+ Essential Settings
+
+
+
+
+
+
+
+
X-Pack Management Settings
+
+
+
+
+
+
+
+
+
+
Pipeline IDs
+
+
+ CPM requires you to specify which pipelines you want to run. Use an * to pull all pipelines stored in Elastic CPM.
+ Click here to set the value to *
+
+
+
+
+
+
+
+
+
Connection Method
+
+
+ Cloud ID
+
+
+ Hosts URL
+
+
+
+
+
+
+
+
+
+
Elasticsearch Hosts
+
+
The URL of one or many Elasticsearch instances
+
+
+
+
+
+
+
Authentication Method
+
+
+ API Key
+
+
+ Username/Password
+
+
+
+
+
+
+
+
+
+
Username
+
+
Elastic username
+
+
+
Password
+
+
Elastic password
+
+
+
+
+
+
+
+
+
+
+
+
+
Pipeline Configuration Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/default.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/default.html
new file mode 100644
index 00000000..7c2bc249
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/logstashyml_guides/default.html
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+
About Default Configuration
+
This is the minimal configuration you need to run Logstash using LogstashUI and LogstashAgent.
+
+
+
+
+
+
+
+
+
+
+
+ Essential Settings
+
+
+
+
+
+
+
+
+
+
Pipeline Configuration Settings
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/pipeline_list_content.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/pipeline_list_content.html
new file mode 100644
index 00000000..71490d68
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/pipeline_list_content.html
@@ -0,0 +1,585 @@
+
+
+
+
+
+
+
+
+ Create Pipeline
+
+
+
+
+
+
+
+ {% if es_id %}
+
+
+
+
+
+
+ Policy:
+ •
+ Changes apply to all agents in this policy
+
+
+ {% endif %}
+
+
+{% if es_id %}
+
+{% endif %}
+
+
+
+
+
No results found
+
Try adjusting your search terms
+
+
+
+
+
+
+ Pipeline Name
+ Description
+ Last Updated
+ Actions
+
+
+
+ {% for pipeline in pipelines %}
+
+
+ {{ pipeline.name }}
+
+
+ {% if pipeline.description %}
+ {{ pipeline.description|truncatechars:80 }}
+ {% else %}
+ No description
+ {% endif %}
+
+
+ {% if pipeline.last_modified %}
+ {{ pipeline.last_modified }}
+ {% else %}
+ N/A
+ {% endif %}
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
Create New Pipeline
+
+
+
+
+
+
+
+
+
+
+
Clone Pipeline:
+
+
+ {% csrf_token %}
+
+
+ New Pipeline Name
+
+
+
+ Enter a name for the cloned pipeline
+
+
+
+
+ {% if policy_id %}
+
+ {% endif %}
+
+ Cancel
+ Clone Pipeline
+
+
+
+
+
+
+
+
+
+
Rename Pipeline:
+
+
+ {% csrf_token %}
+
+
+ New Pipeline Name
+
+
+
+ Enter a new name for the pipeline
+
+
+
+
+ {% if policy_id %}
+
+ {% endif %}
+
+ Cancel
+ Rename Pipeline
+
+
+
+
+
+
+
+
+
+
Update Description:
+
+
+ {% csrf_token %}
+
+
+ Description
+
+
+
+ Update the pipeline description
+
+
+
+
+ {% if policy_id %}
+
+ {% endif %}
+
+ Cancel
+ Update Description
+
+
+
+
+
+
diff --git a/src/logstashui/PipelineManager/templates/components/pipeline_manager/policy_deploy_modal.html b/src/logstashui/PipelineManager/templates/components/pipeline_manager/policy_deploy_modal.html
new file mode 100644
index 00000000..4db798fd
--- /dev/null
+++ b/src/logstashui/PipelineManager/templates/components/pipeline_manager/policy_deploy_modal.html
@@ -0,0 +1,238 @@
+
+
+{% load static %}
+
+
+
+
+
+
+
+
+
+
+
+
Deploy Policy:
+
+ Version:
+
+ →
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
log4j2.properties
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Policy Settings
+
+
+
+
+
+
+
+
+
+
+
Logstash Restart Required
+
By deploying this policy we will restart 0 instance(s) of Logstash as soon as they check in and update their configuration.
+
+
+
+
+
+
+
+
+
No changes to this file are included in this deploy.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+
+
+ Confirm Deploy
+
+
+
+
+
\ No newline at end of file
diff --git a/LogstashUI/PipelineManager/templates/pipeline_editor.html b/src/logstashui/PipelineManager/templates/pipeline_editor.html
similarity index 95%
rename from LogstashUI/PipelineManager/templates/pipeline_editor.html
rename to src/logstashui/PipelineManager/templates/pipeline_editor.html
index cfa248ea..87878d15 100644
--- a/LogstashUI/PipelineManager/templates/pipeline_editor.html
+++ b/src/logstashui/PipelineManager/templates/pipeline_editor.html
@@ -29,6 +29,7 @@
{{ component_data|json_script:"component-data" }}
{{ plugin_data|json_script:"plugin-data" }}
+{{ keystore_keys|default:"[]"|json_script:"keystore-keys" }}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/LogstashUI/PipelineManager/templates/pipeline_text_editor.html b/src/logstashui/PipelineManager/templates/pipeline_text_editor.html
similarity index 100%
rename from LogstashUI/PipelineManager/templates/pipeline_text_editor.html
rename to src/logstashui/PipelineManager/templates/pipeline_text_editor.html
diff --git a/LogstashUI/PipelineManager/templates/settings.html b/src/logstashui/PipelineManager/templates/settings.html
similarity index 100%
rename from LogstashUI/PipelineManager/templates/settings.html
rename to src/logstashui/PipelineManager/templates/settings.html
diff --git a/LogstashUI/Site/__init__.py b/src/logstashui/PipelineManager/tests/__init__.py
similarity index 100%
rename from LogstashUI/Site/__init__.py
rename to src/logstashui/PipelineManager/tests/__init__.py
diff --git a/src/logstashui/PipelineManager/tests/test_agent_api.py b/src/logstashui/PipelineManager/tests/test_agent_api.py
new file mode 100644
index 00000000..963f1c73
--- /dev/null
+++ b/src/logstashui/PipelineManager/tests/test_agent_api.py
@@ -0,0 +1,969 @@
+#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+#or more contributor license agreements. Licensed under the Elastic License;
+#you may not use this file except in compliance with the Elastic License.
+
+from Common.test_resources import test_user
+from PipelineManager.models import (
+ ApiKey, Connection, EnrollmentToken, Keystore, Pipeline, Policy
+)
+
+from datetime import datetime, timezone
+from unittest.mock import patch
+import base64
+import json
+import pytest
+
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+@pytest.fixture
+def test_policy(db):
+ """Create a test policy"""
+ policy = Policy.objects.create(
+ name='Test Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "0.0.0.0"',
+ jvm_options='-Xms1g\n-Xmx1g',
+ log4j2_properties='logger.logstash.name = logstash',
+ keystore_password='test_password'
+ )
+ return policy
+
+
+@pytest.fixture
+def test_enrollment_token(db, test_policy):
+ """Create a test enrollment token"""
+ token = EnrollmentToken.objects.create(
+ policy=test_policy,
+ name='test_token',
+ token='test_enrollment_token_12345'
+ )
+ return token
+
+
+@pytest.fixture
+def test_agent_connection(db, test_policy):
+ """Create a test agent connection"""
+ connection = Connection.objects.create(
+ name='Test Agent',
+ connection_type='AGENT',
+ host='agent.example.com',
+ agent_id='test-agent-001',
+ is_active=True,
+ policy=test_policy
+ )
+ return connection
+
+
+@pytest.fixture
+def test_api_key(db, test_agent_connection):
+ """Create a test API key for agent authentication"""
+ raw_key = 'test_api_key_12345'
+ api_key = ApiKey.objects.create(
+ connection=test_agent_connection,
+ api_key=raw_key
+ )
+ return raw_key
+
+
+@pytest.fixture
+def test_pipeline(db, test_policy):
+ """Create a test pipeline"""
+ pipeline = Pipeline.objects.create(
+ policy=test_policy,
+ name='test_pipeline',
+ lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }',
+ pipeline_workers=2,
+ pipeline_batch_size=256
+ )
+ return pipeline
+
+
+@pytest.fixture
+def test_keystore_entry(db, test_policy):
+ """Create a test keystore entry"""
+ entry = Keystore.objects.create(
+ policy=test_policy,
+ key_name='test_key',
+ key_value='test_value'
+ )
+ return entry
+
+
+# ============================================================================
+# Enroll Endpoint Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestEnrollEndpoint:
+ """Tests for the /enroll agent API endpoint"""
+
+ def test_enroll_success(self, client, test_enrollment_token):
+ """Test successful agent enrollment"""
+ token_payload = {'enrollment_token': test_enrollment_token.token}
+ encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode()
+
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'enrollment_token': encoded_token,
+ 'host': 'new-agent.example.com',
+ 'agent_id': 'new-agent-001'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'api_key' in data
+ assert data['policy_id'] == test_enrollment_token.policy.id
+ assert 'connection_id' in data
+ assert 'policy_config' in data
+
+ # Verify connection was created
+ assert Connection.objects.filter(agent_id='new-agent-001').exists()
+ connection = Connection.objects.get(agent_id='new-agent-001')
+ assert connection.name == 'new-agent.example.com'
+ assert connection.host == 'new-agent.example.com'
+ assert connection.connection_type == 'AGENT'
+ assert connection.policy == test_enrollment_token.policy
+
+ # Verify API key was created
+ assert connection.api_keys.exists()
+
+ def test_enroll_reenrollment_deletes_old_connection(self, client, test_enrollment_token, test_agent_connection):
+ """Test that re-enrolling an agent deletes the old connection"""
+ old_connection_id = test_agent_connection.id
+ agent_id = test_agent_connection.agent_id
+
+ token_payload = {'enrollment_token': test_enrollment_token.token}
+ encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode()
+
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'enrollment_token': encoded_token,
+ 'host': 'updated-agent.example.com',
+ 'agent_id': agent_id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Old connection should be deleted
+ assert not Connection.objects.filter(id=old_connection_id).exists()
+
+ # New connection should exist with same agent_id
+ assert Connection.objects.filter(agent_id=agent_id).exists()
+ new_connection = Connection.objects.get(agent_id=agent_id)
+ assert new_connection.id != old_connection_id
+ assert new_connection.host == 'updated-agent.example.com'
+
+ def test_enroll_missing_enrollment_token(self, client):
+ """Test enrollment with missing enrollment_token"""
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'host': 'agent.example.com',
+ 'agent_id': 'agent-001'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Missing required fields' in data['error']
+
+ def test_enroll_missing_host(self, client, test_enrollment_token):
+ """Test enrollment with missing host"""
+ token_payload = {'enrollment_token': test_enrollment_token.token}
+ encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode()
+
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'enrollment_token': encoded_token,
+ 'agent_id': 'agent-001'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Missing required fields' in data['error']
+
+ def test_enroll_missing_agent_id(self, client, test_enrollment_token):
+ """Test enrollment with missing agent_id"""
+ token_payload = {'enrollment_token': test_enrollment_token.token}
+ encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode()
+
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'enrollment_token': encoded_token,
+ 'host': 'agent.example.com'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Missing required fields' in data['error']
+
+ def test_enroll_invalid_json(self, client):
+ """Test enrollment with invalid JSON"""
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+ def test_enroll_invalid_token_format(self, client):
+ """Test enrollment with invalid base64 token format"""
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'enrollment_token': 'not-valid-base64!!!',
+ 'host': 'agent.example.com',
+ 'agent_id': 'agent-001'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid enrollment token format' in data['error']
+
+ def test_enroll_invalid_token_payload(self, client):
+ """Test enrollment with token missing enrollment_token field"""
+ token_payload = {'wrong_field': 'value'}
+ encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode()
+
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'enrollment_token': encoded_token,
+ 'host': 'agent.example.com',
+ 'agent_id': 'agent-001'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid token payload' in data['error']
+
+ def test_enroll_nonexistent_token(self, client):
+ """Test enrollment with non-existent enrollment token"""
+ token_payload = {'enrollment_token': 'nonexistent_token'}
+ encoded_token = base64.b64encode(json.dumps(token_payload).encode()).decode()
+
+ response = client.post(
+ '/ConnectionManager/Enroll/',
+ data=json.dumps({
+ 'enrollment_token': encoded_token,
+ 'host': 'agent.example.com',
+ 'agent_id': 'agent-001'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid enrollment token' in data['error']
+
+ def test_enroll_wrong_http_method(self, client):
+ """Test that GET requests are rejected"""
+ response = client.get('/ConnectionManager/Enroll/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+
+# ============================================================================
+# CheckIn Endpoint Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestCheckInEndpoint:
+ """Tests for the /check-in agent API endpoint"""
+
+ def test_checkin_success(self, client, test_agent_connection, test_api_key):
+ """Test successful agent check-in"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['message'] == 'Check-in successful'
+ assert 'timestamp' in data
+ assert data['current_revision_number'] == test_agent_connection.policy.current_revision_number
+ assert data['settings_path'] == test_agent_connection.policy.settings_path
+ assert data['restart'] is False
+
+ # Verify last_check_in was updated
+ test_agent_connection.refresh_from_db()
+ assert test_agent_connection.last_check_in is not None
+
+ def test_checkin_with_status_blob(self, client, test_agent_connection, test_api_key):
+ """Test check-in with status blob update"""
+ status_blob = {
+ 'logstash_api': {'accessible': True, 'status': 'green'},
+ 'health_report': {'status': 'green'}
+ }
+
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'status_blob': status_blob
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify status_blob was saved
+ test_agent_connection.refresh_from_db()
+ assert test_agent_connection.status_blob == status_blob
+
+ def test_checkin_restart_flag(self, client, test_agent_connection, test_api_key):
+ """Test check-in with restart flag set"""
+ test_agent_connection.restart_on_next_checkin = True
+ test_agent_connection.save()
+
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['restart'] is True
+
+ # Verify restart flag was cleared
+ test_agent_connection.refresh_from_db()
+ assert test_agent_connection.restart_on_next_checkin is False
+
+ def test_checkin_missing_authorization_header(self, client, test_agent_connection):
+ """Test check-in without Authorization header"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Missing or invalid Authorization header' in data['error']
+
+ def test_checkin_invalid_authorization_format(self, client, test_agent_connection):
+ """Test check-in with invalid Authorization header format"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION='Bearer invalid_format'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Missing or invalid Authorization header' in data['error']
+
+ def test_checkin_empty_api_key(self, client, test_agent_connection):
+ """Test check-in with empty API key"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION='ApiKey '
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'API key is empty' in data['error']
+
+ def test_checkin_missing_connection_id(self, client, test_api_key):
+ """Test check-in without connection_id"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({}),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Missing connection_id' in data['error']
+
+ def test_checkin_invalid_connection_id(self, client, test_api_key):
+ """Test check-in with non-existent connection_id"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': 99999
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid connection_id' in data['error']
+
+ def test_checkin_invalid_api_key(self, client, test_agent_connection):
+ """Test check-in with wrong API key"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION='ApiKey wrong_api_key'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid API key' in data['error']
+
+ def test_checkin_no_policy_assigned(self, client, test_api_key):
+ """Test check-in when connection has no policy"""
+ # Create connection without policy
+ connection = Connection.objects.create(
+ name='No Policy Agent',
+ connection_type='AGENT',
+ host='nopolicy.example.com',
+ agent_id='nopolicy-001',
+ is_active=True,
+ policy=None
+ )
+ raw_key = 'nopolicy_api_key'
+ ApiKey.objects.create(connection=connection, api_key=raw_key)
+
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data=json.dumps({
+ 'connection_id': connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {raw_key}'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'No policy assigned' in data['error']
+
+ def test_checkin_wrong_http_method(self, client):
+ """Test that GET requests are rejected"""
+ response = client.get('/ConnectionManager/CheckIn/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_checkin_invalid_json(self, client, test_api_key):
+ """Test check-in with invalid JSON"""
+ response = client.post(
+ '/ConnectionManager/CheckIn/',
+ data='not valid json',
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# GetConfigChanges Endpoint Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetConfigChangesEndpoint:
+ """Tests for the /get-config-changes agent API endpoint"""
+
+ def test_get_config_changes_no_changes(self, client, test_agent_connection, test_api_key, test_policy):
+ """Test config changes when everything is in sync"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['logstash_yml'] is False
+ assert data['changes']['jvm_options'] is False
+ assert data['changes']['log4j2_properties'] is False
+ assert data['changes']['settings_path'] is False
+ assert data['changes']['logs_path'] is False
+ assert data['changes']['binary_path'] is False
+ assert data['changes']['keystore'] is False
+ assert data['changes']['pipelines'] is False
+
+ def test_get_config_changes_logstash_yml_changed(self, client, test_agent_connection, test_api_key, test_policy):
+ """Test detection of logstash.yml changes"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': 'wrong_hash',
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['logstash_yml'] == test_policy.logstash_yml
+ assert data['changes']['jvm_options'] is False
+
+ def test_get_config_changes_all_config_files_changed(self, client, test_agent_connection, test_api_key, test_policy):
+ """Test detection of all config file changes"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': 'wrong1',
+ 'jvm_options_hash': 'wrong2',
+ 'log4j2_properties_hash': 'wrong3',
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['logstash_yml'] == test_policy.logstash_yml
+ assert data['changes']['jvm_options'] == test_policy.jvm_options
+ assert data['changes']['log4j2_properties'] == test_policy.log4j2_properties
+
+ def test_get_config_changes_paths_changed(self, client, test_agent_connection, test_api_key, test_policy):
+ """Test detection of path changes"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': '/wrong/settings',
+ 'logs_path': '/wrong/logs',
+ 'binary_path': '/wrong/binary',
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['settings_path'] == test_policy.settings_path
+ assert data['changes']['logs_path'] == test_policy.logs_path
+ assert data['changes']['binary_path'] == test_policy.binary_path
+
+ def test_get_config_changes_keystore_new_entry(self, client, test_agent_connection, test_api_key, test_policy, test_keystore_entry):
+ """Test detection of new keystore entry"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['keystore'] is not False
+ assert 'test_key' in data['changes']['keystore']['set']
+ assert len(data['changes']['keystore']['delete']) == 0
+
+ def test_get_config_changes_keystore_delete_entry(self, client, test_agent_connection, test_api_key, test_policy):
+ """Test detection of keystore entry to delete"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {'old_key': 'some_hash'},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['keystore'] is not False
+ assert 'old_key' in data['changes']['keystore']['delete']
+
+ def test_get_config_changes_keystore_password_changed(self, client, test_agent_connection, test_api_key, test_policy, test_keystore_entry):
+ """Test detection of keystore password change"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': 'wrong_password_hash',
+ 'keystore': {},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['keystore_password'] is not False
+ # When password changes, all keystore entries are re-encrypted
+ assert data['changes']['keystore'] is not False
+
+ def test_get_config_changes_pipeline_new(self, client, test_agent_connection, test_api_key, test_policy, test_pipeline):
+ """Test detection of new pipeline"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['pipelines'] is not False
+ assert 'test_pipeline' in data['changes']['pipelines']['set']
+ assert data['changes']['pipelines']['set']['test_pipeline']['lscl'] == test_pipeline.lscl
+
+ def test_get_config_changes_pipeline_delete(self, client, test_agent_connection, test_api_key, test_policy):
+ """Test detection of pipeline to delete"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {'old_pipeline': {'config_hash': 'some_hash'}}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['pipelines'] is not False
+ assert 'old_pipeline' in data['changes']['pipelines']['delete']
+
+ def test_get_config_changes_pipeline_updated(self, client, test_agent_connection, test_api_key, test_policy, test_pipeline):
+ """Test detection of pipeline config change"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id,
+ 'logstash_yml_hash': test_policy.logstash_yml_hash,
+ 'jvm_options_hash': test_policy.jvm_options_hash,
+ 'log4j2_properties_hash': test_policy.log4j2_properties_hash,
+ 'settings_path': test_policy.settings_path,
+ 'logs_path': test_policy.logs_path,
+ 'binary_path': test_policy.binary_path,
+ 'keystore_password_hash': test_policy.keystore_password_hash,
+ 'keystore': {},
+ 'pipelines': {'test_pipeline': {'config_hash': 'wrong_hash'}}
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['changes']['pipelines'] is not False
+ assert 'test_pipeline' in data['changes']['pipelines']['set']
+
+ def test_get_config_changes_missing_connection_id(self, client, test_api_key):
+ """Test get-config-changes without connection_id"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({}),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Connection ID is required' in data['error']
+
+ def test_get_config_changes_missing_authorization(self, client, test_agent_connection):
+ """Test get-config-changes without Authorization header"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid authorization header' in data['error']
+
+ def test_get_config_changes_invalid_authorization_format(self, client, test_agent_connection):
+ """Test get-config-changes with invalid Authorization format"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION='Bearer wrong_format'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid authorization header' in data['error']
+
+ def test_get_config_changes_invalid_connection_id(self, client, test_api_key):
+ """Test get-config-changes with non-existent connection_id"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': 99999
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Connection not found' in data['error']
+
+ def test_get_config_changes_invalid_api_key(self, client, test_agent_connection):
+ """Test get-config-changes with wrong API key"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': test_agent_connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION='ApiKey wrong_key'
+ )
+
+ assert response.status_code == 401
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid API key' in data['error']
+
+ def test_get_config_changes_no_policy(self, client, test_api_key):
+ """Test get-config-changes when connection has no policy"""
+ connection = Connection.objects.create(
+ name='No Policy Agent',
+ connection_type='AGENT',
+ host='nopolicy.example.com',
+ agent_id='nopolicy-002',
+ is_active=True,
+ policy=None
+ )
+ raw_key = 'nopolicy_api_key_2'
+ ApiKey.objects.create(connection=connection, api_key=raw_key)
+
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data=json.dumps({
+ 'connection_id': connection.id
+ }),
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {raw_key}'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'No policy assigned' in data['error']
+
+ def test_get_config_changes_wrong_http_method(self, client):
+ """Test that GET requests are rejected"""
+ response = client.get('/ConnectionManager/GetConfigChanges/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_get_config_changes_invalid_json(self, client, test_api_key):
+ """Test get-config-changes with invalid JSON"""
+ response = client.post(
+ '/ConnectionManager/GetConfigChanges/',
+ data='not valid json',
+ content_type='application/json',
+ HTTP_AUTHORIZATION=f'ApiKey {test_api_key}'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# Helper Function Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestEncryptForAgent:
+ """Tests for the _encrypt_for_agent helper function"""
+
+ def test_encrypt_decrypt_roundtrip(self):
+ """Test that encryption/decryption works correctly"""
+ from PipelineManager.agent_api import _encrypt_for_agent
+ from cryptography.fernet import Fernet
+ import hashlib
+
+ raw_api_key = 'test_key_12345'
+ plaintext = 'secret_value'
+
+ encrypted = _encrypt_for_agent(raw_api_key, plaintext)
+
+ # Decrypt using same key
+ key = base64.urlsafe_b64encode(hashlib.sha256(raw_api_key.encode('utf-8')).digest())
+ decrypted = Fernet(key).decrypt(encrypted.encode('utf-8')).decode('utf-8')
+
+ assert decrypted == plaintext
+
+ def test_different_keys_produce_different_ciphertext(self):
+ """Test that different API keys produce different ciphertext"""
+ from PipelineManager.agent_api import _encrypt_for_agent
+
+ plaintext = 'secret_value'
+ encrypted1 = _encrypt_for_agent('key1', plaintext)
+ encrypted2 = _encrypt_for_agent('key2', plaintext)
+
+ assert encrypted1 != encrypted2
diff --git a/src/logstashui/PipelineManager/tests/test_agent_policies.py b/src/logstashui/PipelineManager/tests/test_agent_policies.py
new file mode 100644
index 00000000..7b8542f9
--- /dev/null
+++ b/src/logstashui/PipelineManager/tests/test_agent_policies.py
@@ -0,0 +1,1033 @@
+#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+#or more contributor license agreements. Licensed under the Elastic License;
+#you may not use this file except in compliance with the Elastic License.
+
+from Common.test_resources import authenticated_client, test_user
+from PipelineManager.models import Connection, Keystore, Pipeline, Policy, Revision
+
+from datetime import datetime, timezone
+from unittest.mock import patch
+import json
+import pytest
+
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+@pytest.fixture
+def test_policy(db):
+ """Create a test policy"""
+ policy = Policy.objects.create(
+ name='Test Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "0.0.0.0"',
+ jvm_options='-Xms1g\n-Xmx1g',
+ log4j2_properties='logger.logstash.name = logstash',
+ keystore_password='test_password'
+ )
+ return policy
+
+
+@pytest.fixture
+def test_policy_with_revision(db, test_user):
+ """Create a test policy with an existing revision"""
+ policy = Policy.objects.create(
+ name='Policy With Revision',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "0.0.0.0"',
+ jvm_options='-Xms1g\n-Xmx1g',
+ log4j2_properties='logger.logstash.name = logstash',
+ current_revision_number=1
+ )
+
+ # Create a revision
+ Revision.objects.create(
+ policy=policy,
+ revision_number=1,
+ snapshot_json={
+ 'logstash_yml': 'http.host: "0.0.0.0"',
+ 'jvm_options': '-Xms1g\n-Xmx1g',
+ 'log4j2_properties': 'logger.logstash.name = logstash',
+ 'settings_path': '/etc/logstash/',
+ 'logs_path': '/var/log/logstash',
+ 'binary_path': '/usr/share/logstash/bin',
+ 'pipelines': [],
+ 'keystore': [],
+ 'keystore_password_hash': ''
+ },
+ created_by=test_user.username
+ )
+
+ return policy
+
+
+@pytest.fixture
+def test_pipeline(db, test_policy):
+ """Create a test pipeline"""
+ pipeline = Pipeline.objects.create(
+ policy=test_policy,
+ name='test_pipeline',
+ lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }',
+ pipeline_workers=2,
+ pipeline_batch_size=256
+ )
+ return pipeline
+
+
+@pytest.fixture
+def test_keystore_entry(db, test_policy):
+ """Create a test keystore entry"""
+ entry = Keystore.objects.create(
+ policy=test_policy,
+ key_name='test_key',
+ key_value='test_value'
+ )
+ return entry
+
+
+@pytest.fixture
+def test_agent_connection(db, test_policy):
+ """Create a test agent connection"""
+ connection = Connection.objects.create(
+ name='Test Agent',
+ connection_type='AGENT',
+ host='agent.example.com',
+ agent_id='test-agent-001',
+ is_active=True,
+ policy=test_policy
+ )
+ return connection
+
+
+# ============================================================================
+# Generate Enrollment Token Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGenerateEnrollmentToken:
+ """Tests for the generate_enrollment_token endpoint"""
+
+ def test_generate_enrollment_token_success(self, authenticated_client):
+ """Test successful enrollment token generation"""
+ response = authenticated_client.post(
+ '/ConnectionManager/GenerateEnrollmentToken/',
+ data=json.dumps({
+ 'policy_name': 'Test Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'enrollment_token' in data
+ assert len(data['enrollment_token']) > 0
+
+ def test_generate_enrollment_token_default_policy(self, authenticated_client):
+ """Test enrollment token generation with default policy name"""
+ response = authenticated_client.post(
+ '/ConnectionManager/GenerateEnrollmentToken/',
+ data=json.dumps({}),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'enrollment_token' in data
+
+ def test_generate_enrollment_token_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/GenerateEnrollmentToken/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_generate_enrollment_token_invalid_json(self, authenticated_client):
+ """Test enrollment token generation with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/GenerateEnrollmentToken/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+ def test_generate_enrollment_token_requires_auth(self, client):
+ """Test that authentication is required"""
+ response = client.post(
+ '/ConnectionManager/GenerateEnrollmentToken/',
+ data=json.dumps({'policy_name': 'Test'}),
+ content_type='application/json'
+ )
+
+ # Should redirect to login or return 403
+ assert response.status_code in [302, 403]
+
+
+# ============================================================================
+# Deploy Policy Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestDeployPolicy:
+ """Tests for the deploy_policy endpoint"""
+
+ def test_deploy_policy_success(self, authenticated_client, test_policy):
+ """Test successful policy deployment"""
+ initial_revision = test_policy.current_revision_number
+
+ response = authenticated_client.post(
+ '/ConnectionManager/DeployPolicy/',
+ data=json.dumps({
+ 'policy_id': test_policy.id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['revision_number'] == initial_revision + 1
+ assert data['policy_name'] == test_policy.name
+
+ # Verify policy was updated
+ test_policy.refresh_from_db()
+ assert test_policy.current_revision_number == initial_revision + 1
+ assert test_policy.last_deployed_at is not None
+
+ # Verify revision was created
+ assert Revision.objects.filter(
+ policy=test_policy,
+ revision_number=initial_revision + 1
+ ).exists()
+
+ def test_deploy_policy_creates_snapshot(self, authenticated_client, test_policy, test_pipeline, test_keystore_entry):
+ """Test that deployment creates proper snapshot"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeployPolicy/',
+ data=json.dumps({
+ 'policy_id': test_policy.id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+
+ # Get the created revision
+ revision = Revision.objects.filter(policy=test_policy).first()
+ assert revision is not None
+
+ # Verify snapshot contains all data
+ snapshot = revision.snapshot_json
+ assert snapshot['logstash_yml'] == test_policy.logstash_yml
+ assert snapshot['jvm_options'] == test_policy.jvm_options
+ assert snapshot['log4j2_properties'] == test_policy.log4j2_properties
+ assert snapshot['settings_path'] == test_policy.settings_path
+ assert len(snapshot['pipelines']) == 1
+ assert snapshot['pipelines'][0]['name'] == 'test_pipeline'
+ assert len(snapshot['keystore']) == 1
+ assert snapshot['keystore'][0]['key_name'] == 'test_key'
+
+ def test_deploy_policy_missing_policy_id(self, authenticated_client):
+ """Test deployment without policy_id"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeployPolicy/',
+ data=json.dumps({}),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_deploy_policy_nonexistent_policy(self, authenticated_client):
+ """Test deployment with non-existent policy"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeployPolicy/',
+ data=json.dumps({
+ 'policy_id': 99999
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+ def test_deploy_policy_wrong_method(self, authenticated_client, test_policy):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/DeployPolicy/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_deploy_policy_invalid_json(self, authenticated_client):
+ """Test deployment with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeployPolicy/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# Get Policy Diff Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPolicyDiff:
+ """Tests for the get_policy_diff endpoint"""
+
+ def test_get_policy_diff_no_revision(self, authenticated_client, test_policy):
+ """Test getting diff when no revision exists"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyDiff/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['policy_name'] == test_policy.name
+ assert data['current_revision'] == 0
+ assert data['last_deployed_revision'] == 0
+ assert 'current' in data
+ assert 'previous' in data
+
+ def test_get_policy_diff_with_revision(self, authenticated_client, test_policy_with_revision):
+ """Test getting diff when revision exists"""
+ # Modify the policy
+ test_policy_with_revision.logstash_yml = 'http.host: "127.0.0.1"'
+ test_policy_with_revision.save()
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyDiff/?policy_id={test_policy_with_revision.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['current_revision'] == 1
+ assert data['last_deployed_revision'] == 1
+ assert data['current']['logstash_yml'] == 'http.host: "127.0.0.1"'
+ assert data['previous']['logstash_yml'] == 'http.host: "0.0.0.0"'
+
+ def test_get_policy_diff_missing_policy_id(self, authenticated_client):
+ """Test getting diff without policy_id"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyDiff/')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_get_policy_diff_nonexistent_policy(self, authenticated_client):
+ """Test getting diff for non-existent policy"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyDiff/?policy_id=99999')
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+ def test_get_policy_diff_wrong_method(self, authenticated_client, test_policy):
+ """Test that POST requests are rejected"""
+ response = authenticated_client.post(
+ '/ConnectionManager/GetPolicyDiff/',
+ data=json.dumps({'policy_id': test_policy.id}),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+
+# ============================================================================
+# Get Policy Agent Count Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPolicyAgentCount:
+ """Tests for the get_policy_agent_count endpoint"""
+
+ def test_get_policy_agent_count_zero(self, authenticated_client, test_policy):
+ """Test agent count when no agents are assigned"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['agent_count'] == 0
+ assert data['policy_name'] == test_policy.name
+
+ def test_get_policy_agent_count_with_agents(self, authenticated_client, test_policy, test_agent_connection):
+ """Test agent count with assigned agents"""
+ # Create another agent
+ Connection.objects.create(
+ name='Test Agent 2',
+ connection_type='AGENT',
+ host='agent2.example.com',
+ agent_id='test-agent-002',
+ is_active=True,
+ policy=test_policy
+ )
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['agent_count'] == 2
+
+ def test_get_policy_agent_count_excludes_inactive(self, authenticated_client, test_policy):
+ """Test that inactive agents are not counted"""
+ Connection.objects.create(
+ name='Inactive Agent',
+ connection_type='AGENT',
+ host='inactive.example.com',
+ agent_id='inactive-001',
+ is_active=False,
+ policy=test_policy
+ )
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['agent_count'] == 0
+
+ def test_get_policy_agent_count_excludes_centralized(self, authenticated_client, test_policy):
+ """Test that centralized connections are not counted"""
+ Connection.objects.create(
+ name='Centralized',
+ connection_type='CENTRALIZED',
+ host='https://localhost:9200',
+ username='elastic',
+ password='changeme',
+ is_active=True
+ )
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyAgentCount/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['agent_count'] == 0
+
+ def test_get_policy_agent_count_missing_policy_id(self, authenticated_client):
+ """Test agent count without policy_id"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyAgentCount/')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_get_policy_agent_count_nonexistent_policy(self, authenticated_client):
+ """Test agent count for non-existent policy"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyAgentCount/?policy_id=99999')
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+
+# ============================================================================
+# Get Policy Change Count Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPolicyChangeCount:
+ """Tests for the get_policy_change_count endpoint"""
+
+ def test_get_policy_change_count_no_changes(self, authenticated_client, test_policy_with_revision):
+ """Test change count when no changes exist"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['pending_changes'] == 0
+
+ def test_get_policy_change_count_config_file_changes(self, authenticated_client, test_policy_with_revision):
+ """Test change count with config file changes"""
+ test_policy_with_revision.logstash_yml = 'http.host: "127.0.0.1"'
+ test_policy_with_revision.jvm_options = '-Xms2g\n-Xmx2g'
+ test_policy_with_revision.save()
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['pending_changes'] == 2 # logstash_yml + jvm_options
+
+ def test_get_policy_change_count_pipeline_changes(self, authenticated_client, test_policy_with_revision):
+ """Test change count with pipeline changes"""
+ Pipeline.objects.create(
+ policy=test_policy_with_revision,
+ name='new_pipeline',
+ lscl='input {} filter {} output {}'
+ )
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['pending_changes'] == 1 # pipelines changed
+
+ def test_get_policy_change_count_keystore_changes(self, authenticated_client, test_policy_with_revision):
+ """Test change count with keystore changes"""
+ Keystore.objects.create(
+ policy=test_policy_with_revision,
+ key_name='new_key',
+ key_value='new_value'
+ )
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['pending_changes'] == 1 # keystore changed
+
+ def test_get_policy_change_count_global_settings_changes(self, authenticated_client, test_policy_with_revision):
+ """Test change count with global settings changes"""
+ test_policy_with_revision.settings_path = '/new/settings'
+ test_policy_with_revision.save()
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert data['pending_changes'] == 1 # global_settings changed
+
+ def test_get_policy_change_count_all_changes(self, authenticated_client, test_policy_with_revision):
+ """Test change count with all types of changes"""
+ # Config files
+ test_policy_with_revision.logstash_yml = 'http.host: "127.0.0.1"'
+ test_policy_with_revision.jvm_options = '-Xms2g\n-Xmx2g'
+ test_policy_with_revision.log4j2_properties = 'logger.logstash.level = debug'
+ # Global settings
+ test_policy_with_revision.settings_path = '/new/settings'
+ # Keystore password
+ test_policy_with_revision.keystore_password = 'new_password'
+ test_policy_with_revision.save()
+
+ # Pipeline
+ Pipeline.objects.create(
+ policy=test_policy_with_revision,
+ name='new_pipeline',
+ lscl='input {} filter {} output {}'
+ )
+
+ # Keystore
+ Keystore.objects.create(
+ policy=test_policy_with_revision,
+ key_name='new_key',
+ key_value='new_value'
+ )
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy_with_revision.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ # 3 config files + 1 pipelines + 1 keystore + 1 keystore_password + 1 global_settings = 7
+ assert data['pending_changes'] == 7
+
+ def test_get_policy_change_count_no_revision(self, authenticated_client, test_policy):
+ """Test change count when no revision exists (all changes)"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyChangeCount/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ # Should count all sections as changed when no revision exists
+ assert data['pending_changes'] > 0
+
+ def test_get_policy_change_count_missing_policy_id(self, authenticated_client):
+ """Test change count without policy_id"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyChangeCount/')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_get_policy_change_count_nonexistent_policy(self, authenticated_client):
+ """Test change count for non-existent policy"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyChangeCount/?policy_id=99999')
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+
+# ============================================================================
+# Get Keystore Entries Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetKeystoreEntries:
+ """Tests for the get_keystore_entries endpoint"""
+
+ def test_get_keystore_entries_empty(self, authenticated_client, test_policy):
+ """Test getting keystore entries when none exist"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetKeystoreEntries/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['entries']) == 0
+ assert data['has_keystore_password'] is True # test_policy has password
+
+ def test_get_keystore_entries_with_entries(self, authenticated_client, test_policy, test_keystore_entry):
+ """Test getting keystore entries"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetKeystoreEntries/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['entries']) == 1
+ assert data['entries'][0]['key_name'] == 'test_key'
+ assert 'id' in data['entries'][0]
+ assert 'last_updated' in data['entries'][0]
+
+ def test_get_keystore_entries_missing_policy_id(self, authenticated_client):
+ """Test getting entries without policy_id"""
+ response = authenticated_client.get('/ConnectionManager/GetKeystoreEntries/')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_get_keystore_entries_nonexistent_policy(self, authenticated_client):
+ """Test getting entries for non-existent policy"""
+ response = authenticated_client.get('/ConnectionManager/GetKeystoreEntries/?policy_id=99999')
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+
+# ============================================================================
+# Set Keystore Password Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestSetKeystorePassword:
+ """Tests for the set_keystore_password endpoint"""
+
+ def test_set_keystore_password_success(self, authenticated_client, test_policy):
+ """Test setting keystore password"""
+ response = authenticated_client.post(
+ '/ConnectionManager/SetKeystorePassword/',
+ data=json.dumps({
+ 'policy_id': test_policy.id,
+ 'password': 'new_secure_password'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'Keystore password updated' in data['message']
+
+ # Verify password was set and encrypted
+ test_policy.refresh_from_db()
+ assert test_policy.keystore_password is not None
+ assert test_policy.keystore_password != 'new_secure_password' # Should be encrypted
+ assert test_policy.has_undeployed_changes is True
+
+ def test_set_keystore_password_missing_policy_id(self, authenticated_client):
+ """Test setting password without policy_id"""
+ response = authenticated_client.post(
+ '/ConnectionManager/SetKeystorePassword/',
+ data=json.dumps({
+ 'password': 'test_password'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_set_keystore_password_missing_password(self, authenticated_client, test_policy):
+ """Test setting password without password field"""
+ response = authenticated_client.post(
+ '/ConnectionManager/SetKeystorePassword/',
+ data=json.dumps({
+ 'policy_id': test_policy.id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Password cannot be empty' in data['error']
+
+ def test_set_keystore_password_nonexistent_policy(self, authenticated_client):
+ """Test setting password for non-existent policy"""
+ response = authenticated_client.post(
+ '/ConnectionManager/SetKeystorePassword/',
+ data=json.dumps({
+ 'policy_id': 99999,
+ 'password': 'test_password'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+ def test_set_keystore_password_wrong_method(self, authenticated_client, test_policy):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/SetKeystorePassword/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_set_keystore_password_invalid_json(self, authenticated_client):
+ """Test setting password with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/SetKeystorePassword/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# Create Keystore Entry Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestCreateKeystoreEntry:
+ """Tests for the create_keystore_entry endpoint"""
+
+ def test_create_keystore_entry_success(self, authenticated_client, test_policy):
+ """Test creating a keystore entry"""
+ response = authenticated_client.post(
+ '/ConnectionManager/CreateKeystoreEntry/',
+ data=json.dumps({
+ 'policy_id': test_policy.id,
+ 'key_name': 'new_key',
+ 'key_value': 'secret_value'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'created successfully' in data['message']
+ assert 'entry_id' in data
+
+ # Verify entry was created
+ assert Keystore.objects.filter(
+ policy=test_policy,
+ key_name='new_key'
+ ).exists()
+
+ def test_create_keystore_entry_duplicate_key(self, authenticated_client, test_policy, test_keystore_entry):
+ """Test creating a duplicate keystore entry"""
+ response = authenticated_client.post(
+ '/ConnectionManager/CreateKeystoreEntry/',
+ data=json.dumps({
+ 'policy_id': test_policy.id,
+ 'key_name': 'test_key', # Already exists
+ 'key_value': 'another_value'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'already exists' in data['error']
+
+ def test_create_keystore_entry_missing_fields(self, authenticated_client, test_policy):
+ """Test creating entry with missing fields"""
+ response = authenticated_client.post(
+ '/ConnectionManager/CreateKeystoreEntry/',
+ data=json.dumps({
+ 'policy_id': test_policy.id,
+ 'key_name': 'test_key'
+ # Missing key_value
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'required' in data['error']
+
+ def test_create_keystore_entry_nonexistent_policy(self, authenticated_client):
+ """Test creating entry for non-existent policy"""
+ response = authenticated_client.post(
+ '/ConnectionManager/CreateKeystoreEntry/',
+ data=json.dumps({
+ 'policy_id': 99999,
+ 'key_name': 'test_key',
+ 'key_value': 'test_value'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+ def test_create_keystore_entry_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/CreateKeystoreEntry/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_create_keystore_entry_invalid_json(self, authenticated_client):
+ """Test creating entry with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/CreateKeystoreEntry/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# Update Keystore Entry Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestUpdateKeystoreEntry:
+ """Tests for the update_keystore_entry endpoint"""
+
+ def test_update_keystore_entry_success(self, authenticated_client, test_keystore_entry):
+ """Test updating a keystore entry"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdateKeystoreEntry/',
+ data=json.dumps({
+ 'entry_id': test_keystore_entry.id,
+ 'key_value': 'updated_value'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'updated successfully' in data['message']
+
+ # Verify entry was updated
+ test_keystore_entry.refresh_from_db()
+ # Value should be encrypted, so we can't compare directly
+ assert test_keystore_entry.key_value != 'test_value'
+
+ def test_update_keystore_entry_missing_fields(self, authenticated_client, test_keystore_entry):
+ """Test updating entry with missing fields"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdateKeystoreEntry/',
+ data=json.dumps({
+ 'entry_id': test_keystore_entry.id
+ # Missing key_value
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'required' in data['error']
+
+ def test_update_keystore_entry_nonexistent(self, authenticated_client):
+ """Test updating non-existent entry"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdateKeystoreEntry/',
+ data=json.dumps({
+ 'entry_id': 99999,
+ 'key_value': 'test_value'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'not found' in data['error']
+
+ def test_update_keystore_entry_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/UpdateKeystoreEntry/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_update_keystore_entry_invalid_json(self, authenticated_client):
+ """Test updating entry with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdateKeystoreEntry/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# Delete Keystore Entry Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestDeleteKeystoreEntry:
+ """Tests for the delete_keystore_entry endpoint"""
+
+ def test_delete_keystore_entry_success(self, authenticated_client, test_keystore_entry):
+ """Test deleting a keystore entry"""
+ entry_id = test_keystore_entry.id
+
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteKeystoreEntry/',
+ data=json.dumps({
+ 'entry_id': entry_id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'deleted successfully' in data['message']
+
+ # Verify entry was deleted
+ assert not Keystore.objects.filter(id=entry_id).exists()
+
+ def test_delete_keystore_entry_missing_entry_id(self, authenticated_client):
+ """Test deleting entry without entry_id"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteKeystoreEntry/',
+ data=json.dumps({}),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Entry ID is required' in data['error']
+
+ def test_delete_keystore_entry_nonexistent(self, authenticated_client):
+ """Test deleting non-existent entry"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteKeystoreEntry/',
+ data=json.dumps({
+ 'entry_id': 99999
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'not found' in data['error']
+
+ def test_delete_keystore_entry_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/DeleteKeystoreEntry/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_delete_keystore_entry_invalid_json(self, authenticated_client):
+ """Test deleting entry with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteKeystoreEntry/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
diff --git a/src/logstashui/PipelineManager/tests/test_connections_crud.py b/src/logstashui/PipelineManager/tests/test_connections_crud.py
new file mode 100644
index 00000000..a9358c3a
--- /dev/null
+++ b/src/logstashui/PipelineManager/tests/test_connections_crud.py
@@ -0,0 +1,603 @@
+#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+#or more contributor license agreements. Licensed under the Elastic License;
+#you may not use this file except in compliance with the Elastic License.
+
+from Common.test_resources import authenticated_client, test_connection, test_user
+from PipelineManager.models import Connection, Policy, Pipeline
+
+from unittest.mock import patch, MagicMock
+from django.conf import settings
+import json
+import pytest
+
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+@pytest.fixture
+def test_policy(db):
+ """Create a test policy"""
+ policy = Policy.objects.create(
+ name='Test Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "0.0.0.0"',
+ jvm_options='-Xms1g\n-Xmx1g',
+ log4j2_properties='logger.logstash.name = logstash'
+ )
+ return policy
+
+
+@pytest.fixture
+def test_agent_connection(db, test_policy):
+ """Create a test agent connection"""
+ connection = Connection.objects.create(
+ name='Test Agent',
+ connection_type='AGENT',
+ host='agent.example.com',
+ agent_id='test-agent-001',
+ is_active=True,
+ policy=test_policy
+ )
+ return connection
+
+
+@pytest.fixture
+def test_pipeline(db, test_policy):
+ """Create a test pipeline"""
+ pipeline = Pipeline.objects.create(
+ policy=test_policy,
+ name='test_pipeline',
+ lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }',
+ description='Test pipeline description'
+ )
+ return pipeline
+
+
+# ============================================================================
+# GetConnections Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetConnections:
+ """Tests for the GetConnections view"""
+
+ def test_returns_json_list(self, authenticated_client, test_connection):
+ """Returns a JSON list of connection dicts"""
+ response = authenticated_client.get('/ConnectionManager/GetConnections/')
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, list)
+ # At least our test_connection
+ ids = [c['id'] for c in data]
+ assert test_connection.id in ids
+
+ def test_returns_expected_fields(self, authenticated_client, test_connection):
+ """Each connection dict has id, name, connection_type"""
+ response = authenticated_client.get('/ConnectionManager/GetConnections/')
+ item = response.json()[0]
+ assert 'id' in item
+ assert 'name' in item
+ assert 'connection_type' in item
+
+ def test_returns_all_connections(self, authenticated_client, test_connection, test_agent_connection):
+ """Returns all connections regardless of type"""
+ response = authenticated_client.get('/ConnectionManager/GetConnections/')
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) >= 2
+ ids = [c['id'] for c in data]
+ assert test_connection.id in ids
+ assert test_agent_connection.id in ids
+
+ def test_handles_empty_connections(self, authenticated_client):
+ """Returns empty list when no connections exist"""
+ Connection.objects.all().delete()
+ response = authenticated_client.get('/ConnectionManager/GetConnections/')
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, list)
+ assert len(data) == 0
+
+
+# ============================================================================
+# AddConnection Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestAddConnection:
+ """Test Connection Create operations"""
+
+ def test_add_connection_requires_authentication(self, client):
+ """Test that adding a connection requires authentication"""
+ response = client.post('/ConnectionManager/AddConnection', {
+ 'name': 'Test',
+ 'connection_type': 'CENTRALIZED',
+ 'host': 'https://localhost:9200'
+ })
+ # Should redirect to login
+ assert response.status_code == 302
+ assert '/Management/Login/' in response.url
+
+ @patch('PipelineManager.manager_views.test_connectivity')
+ def test_add_connection_success(self, mock_test_connectivity, authenticated_client):
+ """Test successful connection creation"""
+ # Mock successful connectivity test
+ mock_test_connectivity.return_value = (True, "Connection successful")
+
+ response = authenticated_client.post('/ConnectionManager/AddConnection', {
+ 'name': 'Test Connection',
+ 'connection_type': 'CENTRALIZED',
+ 'host': 'https://localhost:9200',
+ 'username': 'elastic',
+ 'password': 'changeme'
+ })
+
+ assert response.status_code == 200
+ response_data = json.loads(response.content)
+ assert response_data['success'] is True
+ assert 'Connection created and tested successfully!' in response_data['message']
+ assert 'connection_id' in response_data
+
+ # Verify connection was created
+ assert Connection.objects.filter(name='Test Connection').exists()
+
+ @patch('PipelineManager.manager_views.test_connectivity')
+ def test_add_connection_failed_connectivity(self, mock_test_connectivity, authenticated_client):
+ """Test connection creation with failed connectivity test"""
+ # Mock failed connectivity test
+ mock_test_connectivity.return_value = (False, "Connection failed: Timeout")
+
+ response = authenticated_client.post('/ConnectionManager/AddConnection', {
+ 'name': 'Bad Connection',
+ 'connection_type': 'CENTRALIZED',
+ 'host': 'https://invalid:9200',
+ 'username': 'elastic',
+ 'password': 'wrong'
+ })
+
+ assert response.status_code == 200
+ response_data = json.loads(response.content)
+ assert response_data['success'] is False
+ assert 'Connection failed: Timeout' in response_data['error']
+
+ # Verify connection was NOT created (deleted after failed test)
+ assert not Connection.objects.filter(name='Bad Connection').exists()
+
+ def test_add_connection_invalid_form(self, authenticated_client):
+ """Test connection creation with invalid form data"""
+ response = authenticated_client.post('/ConnectionManager/AddConnection', {
+ 'name': '', # Empty name should fail validation
+ 'connection_type': 'CENTRALIZED'
+ })
+
+ assert response.status_code == 200
+ response_data = json.loads(response.content)
+ assert response_data['success'] is False
+ assert 'error' in response_data
+ # Check that the error contains form validation messages
+ assert 'name' in response_data['error'] or 'This field is required' in response_data['error']
+
+ def test_add_connection_get_returns_405(self, authenticated_client):
+ """AddConnection only accepts POST — GET returns 405"""
+ response = authenticated_client.get('/ConnectionManager/AddConnection')
+ assert response.status_code == 405
+
+
+# ============================================================================
+# DeleteConnection Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestDeleteConnection:
+ """Test Connection Delete operations"""
+
+ def test_delete_connection(self, authenticated_client, test_connection):
+ """Test connection deletion"""
+ connection_id = test_connection.id
+
+ response = authenticated_client.post(f'/ConnectionManager/DeleteConnection/{connection_id}/')
+
+ assert response.status_code == 200
+ assert b'Connection deleted successfully!' in response.content
+
+ # Verify connection was deleted
+ assert not Connection.objects.filter(id=connection_id).exists()
+
+ def test_delete_nonexistent_connection(self, authenticated_client):
+ """Test deleting a connection that doesn't exist"""
+ response = authenticated_client.post('/ConnectionManager/DeleteConnection/99999/')
+
+ assert response.status_code == 404
+ assert b'Connection not found' in response.content
+
+ def test_delete_connection_missing_id(self, authenticated_client):
+ """Test deleting without connection_id"""
+ response = authenticated_client.post('/ConnectionManager/DeleteConnection/')
+
+ assert response.status_code in [400, 404] # Either bad request or not found
+
+ def test_delete_connection_get_returns_405(self, authenticated_client, test_connection):
+ """DeleteConnection only accepts POST — GET returns 405"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/DeleteConnection/{test_connection.id}/'
+ )
+ assert response.status_code == 405
+
+
+# ============================================================================
+# UpgradeAgent Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestUpgradeAgent:
+ """Tests for the UpgradeAgent endpoint"""
+
+ def test_upgrade_agent_success(self, authenticated_client, test_agent_connection):
+ """Test successful agent upgrade request"""
+ response = authenticated_client.post(
+ f'/ConnectionManager/UpgradeAgent/{test_agent_connection.id}/'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'upgrade' in data['message'].lower()
+
+ # Verify desired_agent_version was set
+ test_agent_connection.refresh_from_db()
+ assert test_agent_connection.desired_agent_version == settings.__PREFERRED_LS_AGENT_VERSION__
+
+ def test_upgrade_agent_missing_id(self, authenticated_client):
+ """Test upgrade without connection_id"""
+ response = authenticated_client.post('/ConnectionManager/UpgradeAgent/')
+
+ assert response.status_code in [400, 404]
+ if response.status_code == 400:
+ data = response.json()
+ assert data['success'] is False
+ assert 'Connection ID is required' in data['error']
+
+ def test_upgrade_agent_nonexistent(self, authenticated_client):
+ """Test upgrade for non-existent connection"""
+ response = authenticated_client.post('/ConnectionManager/UpgradeAgent/99999/')
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Connection not found' in data['error']
+
+ def test_upgrade_agent_centralized_connection(self, authenticated_client, test_connection):
+ """Test that centralized connections cannot be upgraded"""
+ response = authenticated_client.post(
+ f'/ConnectionManager/UpgradeAgent/{test_connection.id}/'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Only agent connections can be upgraded' in data['error']
+
+ def test_upgrade_agent_wrong_method(self, authenticated_client, test_agent_connection):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/UpgradeAgent/{test_agent_connection.id}/'
+ )
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+
+# ============================================================================
+# ChangeConnectionPolicy Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestChangeConnectionPolicy:
+ """Tests for the change_connection_policy endpoint"""
+
+ def test_change_policy_success(self, authenticated_client, test_agent_connection, test_policy):
+ """Test successful policy change"""
+ # Create a new policy
+ new_policy = Policy.objects.create(
+ name='New Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "127.0.0.1"',
+ jvm_options='-Xms2g\n-Xmx2g',
+ log4j2_properties='logger.logstash.name = logstash'
+ )
+
+ response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', {
+ 'connection_id': test_agent_connection.id,
+ 'policy_id': new_policy.id
+ })
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify policy was changed
+ test_agent_connection.refresh_from_db()
+ assert test_agent_connection.policy == new_policy
+
+ def test_change_policy_missing_connection_id(self, authenticated_client, test_policy):
+ """Test policy change without connection_id"""
+ response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', {
+ 'policy_id': test_policy.id
+ })
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'connection not found' in data['error'].lower()
+
+ def test_change_policy_missing_policy_id(self, authenticated_client, test_agent_connection):
+ """Test policy change without policy_id"""
+ response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', {
+ 'connection_id': test_agent_connection.id
+ })
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+ def test_change_policy_nonexistent_connection(self, authenticated_client, test_policy):
+ """Test policy change for non-existent connection"""
+ response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', {
+ 'connection_id': 99999,
+ 'policy_id': test_policy.id
+ })
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'connection not found' in data['error'].lower()
+
+ def test_change_policy_nonexistent_policy(self, authenticated_client, test_agent_connection):
+ """Test policy change to non-existent policy"""
+ response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', {
+ 'connection_id': test_agent_connection.id,
+ 'policy_id': 99999
+ })
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+ def test_change_policy_centralized_connection(self, authenticated_client, test_connection, test_policy):
+ """Test that centralized connections cannot have policy changed"""
+ response = authenticated_client.post('/ConnectionManager/ChangeConnectionPolicy/', {
+ 'connection_id': test_connection.id,
+ 'policy_id': test_policy.id
+ })
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+
+ def test_change_policy_wrong_method(self, authenticated_client, test_agent_connection, test_policy):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/ChangeConnectionPolicy/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+
+# ============================================================================
+# RestartLogstash Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestRestartLogstash:
+ """Tests for the restart_logstash endpoint"""
+
+ def test_restart_logstash_success(self, authenticated_client, test_agent_connection):
+ """Test successful restart request"""
+ response = authenticated_client.post('/ConnectionManager/RestartLogstash/', {
+ 'connection_id': test_agent_connection.id
+ })
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify restart flag was set
+ test_agent_connection.refresh_from_db()
+ assert test_agent_connection.restart_on_next_checkin is True
+
+ def test_restart_logstash_missing_connection_id(self, authenticated_client):
+ """Test restart without connection_id"""
+ response = authenticated_client.post('/ConnectionManager/RestartLogstash/', {})
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'connection not found' in data['error'].lower()
+
+ def test_restart_logstash_nonexistent_connection(self, authenticated_client):
+ """Test restart for non-existent connection"""
+ response = authenticated_client.post('/ConnectionManager/RestartLogstash/', {
+ 'connection_id': 99999
+ })
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'connection not found' in data['error'].lower()
+
+ def test_restart_logstash_centralized_connection(self, authenticated_client, test_connection):
+ """Test that centralized connections cannot be restarted"""
+ response = authenticated_client.post('/ConnectionManager/RestartLogstash/', {
+ 'connection_id': test_connection.id
+ })
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+
+ def test_restart_logstash_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/RestartLogstash/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+
+# ============================================================================
+# GetPipelines Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPipelines:
+ """Tests for the GetPipelines endpoint"""
+
+ @patch('PipelineManager.connections_crud.get_elastic_connection')
+ def test_get_pipelines_centralized_success(self, mock_get_es, authenticated_client, test_connection):
+ """Test getting pipelines for centralized connection"""
+ # Mock Elasticsearch connection
+ mock_es = MagicMock()
+ mock_es.logstash.get_pipeline.return_value = {
+ 'test_pipeline': {
+ 'description': 'Test pipeline',
+ 'last_modified': '2025-01-14T12:00:00.000Z'
+ }
+ }
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{test_connection.id}/')
+
+ assert response.status_code == 200
+ assert b'test_pipeline' in response.content
+
+ def test_get_pipelines_agent_success(self, authenticated_client, test_agent_connection, test_pipeline):
+ """Test getting pipelines for agent connection"""
+ response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{test_agent_connection.id}/')
+
+ assert response.status_code == 200
+ assert b'test_pipeline' in response.content
+ assert b'Test pipeline description' in response.content
+
+ def test_get_pipelines_agent_no_policy(self, authenticated_client):
+ """Test getting pipelines for agent without policy"""
+ agent = Connection.objects.create(
+ name='No Policy Agent',
+ connection_type='AGENT',
+ host='nopolicy.example.com',
+ agent_id='nopolicy-001',
+ is_active=True,
+ policy=None
+ )
+
+ response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{agent.id}/')
+
+ assert response.status_code == 200
+ # Should return empty pipelines list
+
+ def test_get_pipelines_nonexistent_connection(self, authenticated_client):
+ """Test getting pipelines for non-existent connection"""
+ response = authenticated_client.get('/ConnectionManager/GetPipelines/99999/')
+
+ assert response.status_code == 404
+ assert b'Connection not found' in response.content
+
+ @patch('PipelineManager.connections_crud.get_elastic_connection')
+ def test_get_pipelines_centralized_connection_error(self, mock_get_es, authenticated_client, test_connection):
+ """Test handling of Elasticsearch connection errors"""
+ # Mock connection error
+ mock_get_es.side_effect = Exception("Connection failed")
+
+ response = authenticated_client.get(f'/ConnectionManager/GetPipelines/{test_connection.id}/')
+
+ # Should still return 200 but with empty pipelines
+ assert response.status_code == 200
+
+
+# ============================================================================
+# GetPolicyPipelines Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPolicyPipelines:
+ """Tests for the GetPolicyPipelines endpoint"""
+
+ def test_get_policy_pipelines_success(self, authenticated_client, test_policy, test_pipeline):
+ """Test getting pipelines for a policy"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyPipelines/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['pipelines']) == 1
+ assert data['pipelines'][0]['name'] == 'test_pipeline'
+ assert data['pipelines'][0]['description'] == 'Test pipeline description'
+
+ def test_get_policy_pipelines_empty(self, authenticated_client, test_policy):
+ """Test getting pipelines when policy has none"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyPipelines/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['pipelines']) == 0
+
+ def test_get_policy_pipelines_missing_policy_id(self, authenticated_client):
+ """Test getting pipelines without policy_id"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyPipelines/')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_get_policy_pipelines_nonexistent_policy(self, authenticated_client):
+ """Test getting pipelines for non-existent policy"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicyPipelines/?policy_id=99999')
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'not found' in data['error']
+
+ def test_get_policy_pipelines_multiple(self, authenticated_client, test_policy):
+ """Test getting multiple pipelines for a policy"""
+ # Create multiple pipelines
+ Pipeline.objects.create(
+ policy=test_policy,
+ name='pipeline1',
+ lscl='input {} filter {} output {}',
+ description='Pipeline 1'
+ )
+ Pipeline.objects.create(
+ policy=test_policy,
+ name='pipeline2',
+ lscl='input {} filter {} output {}',
+ description='Pipeline 2'
+ )
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPolicyPipelines/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['pipelines']) == 2
+ names = [p['name'] for p in data['pipelines']]
+ assert 'pipeline1' in names
+ assert 'pipeline2' in names
diff --git a/src/logstashui/PipelineManager/tests/test_editor_views.py b/src/logstashui/PipelineManager/tests/test_editor_views.py
new file mode 100644
index 00000000..25471cad
--- /dev/null
+++ b/src/logstashui/PipelineManager/tests/test_editor_views.py
@@ -0,0 +1,751 @@
+#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+#or more contributor license agreements. Licensed under the Elastic License;
+#you may not use this file except in compliance with the Elastic License.
+
+from Common.test_resources import authenticated_client, test_connection, test_user
+
+from unittest.mock import patch, MagicMock
+
+import json
+import pytest
+
+
+# ============================================================================
+# RBAC Tests for Simulation and Pipeline Editor Endpoints
+# ============================================================================
+
+@pytest.mark.django_db
+class TestRBACSimulationEndpoints:
+ """Test RBAC (Role-Based Access Control) for simulation endpoints"""
+
+ def test_readonly_user_cannot_simulate_pipeline(self, client):
+ """Test that readonly user cannot access SimulatePipeline"""
+ from django.contrib.auth.models import User
+ from Management.models import UserProfile
+
+ readonly_user = User.objects.create_user(
+ username='readonly_simulate',
+ password='testpass123',
+ is_staff=False
+ )
+ readonly_user.profile.role = 'readonly'
+ readonly_user.profile.save()
+ client.login(username='readonly_simulate', password='testpass123')
+
+ components = {
+ "input": [],
+ "filter": [{"id": "filter_1", "plugin": "mutate", "config": {}}],
+ "output": []
+ }
+
+ response = client.post('/ConnectionManager/SimulatePipeline/', {
+ 'components': json.dumps(components),
+ 'log_text': '{"message": "test"}'
+ })
+
+ assert response.status_code == 403
+
+ def test_readonly_user_cannot_upload_file(self, client):
+ """Test that readonly user cannot upload files"""
+ from django.contrib.auth.models import User
+ from Management.models import UserProfile
+ from django.core.files.uploadedfile import SimpleUploadedFile
+
+ readonly_user = User.objects.create_user(
+ username='readonly_upload_rbac',
+ password='testpass123',
+ is_staff=False
+ )
+ readonly_user.profile.role = 'readonly'
+ readonly_user.profile.save()
+ client.login(username='readonly_upload_rbac', password='testpass123')
+
+ file_content = b'test content'
+ uploaded_file = SimpleUploadedFile("test.txt", file_content)
+
+ response = client.post('/ConnectionManager/UploadFile/', {
+ 'file': uploaded_file,
+ 'filename': 'test.txt'
+ })
+
+ assert response.status_code == 403
+
+ def test_readonly_user_can_view_simulation_results(self, authenticated_client):
+ """Test that readonly users can view simulation results (read-only operation)"""
+ # GetSimulationResults doesn't have @require_admin_role, so readonly users can access
+ response = authenticated_client.get('/ConnectionManager/GetSimulationResults/?run_id=test-123')
+
+ # Should work (returns 200 with empty results)
+ assert response.status_code == 200
+
+ def test_readonly_user_can_check_pipeline_loaded(self, authenticated_client):
+ """Test that readonly users can check if pipeline is loaded (read-only operation)"""
+ # CheckIfPipelineLoaded has @login_required but not @require_admin_role
+ response = authenticated_client.get('/ConnectionManager/CheckIfPipelineLoaded/?pipeline_name=test')
+
+ # Should work (may return error about missing pipeline, but not 403)
+ assert response.status_code in [200, 400, 500]
+
+ def test_readonly_user_can_get_related_logs(self, authenticated_client):
+ """Test that readonly users can get related logs (read-only operation)"""
+ # GetRelatedLogs has @login_required but not @require_admin_role
+ response = authenticated_client.get('/ConnectionManager/GetRelatedLogs/?slot_id=1')
+
+ # Should work (may return error, but not 403)
+ assert response.status_code in [200, 400, 500]
+
+ def test_admin_user_can_simulate_pipeline(self, authenticated_client):
+ """Test that admin user can access SimulatePipeline"""
+ components = {
+ "input": [],
+ "filter": [{"id": "filter_1", "plugin": "mutate", "config": {}}],
+ "output": []
+ }
+
+ with patch('PipelineManager.simulation.requests.post') as mock_post:
+ mock_post.return_value.status_code = 200
+ mock_post.return_value.json.return_value = {'slot_id': 1, 'reused': False}
+
+ response = authenticated_client.post('/ConnectionManager/SimulatePipeline/', {
+ 'components': json.dumps(components),
+ 'log_text': '{"message": "test"}'
+ })
+
+ # Should work for admin
+ assert response.status_code == 200
+
+ def test_unauthenticated_user_cannot_simulate(self, client):
+ """Test that unauthenticated users cannot access SimulatePipeline"""
+ components = {
+ "input": [],
+ "filter": [{"id": "filter_1", "plugin": "mutate", "config": {}}],
+ "output": []
+ }
+
+ response = client.post('/ConnectionManager/SimulatePipeline/', {
+ 'components': json.dumps(components),
+ 'log_text': '{"message": "test"}'
+ })
+
+ # Should redirect to login
+ assert response.status_code == 302
+ assert '/Management/Login/' in response.url
+
+
+@pytest.mark.django_db
+class TestRBACPipelineEditorEndpoints:
+ """Test RBAC for pipeline editor endpoints"""
+
+ @patch('PipelineManager.editor_views.get_elastic_connection')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
+ def test_readonly_user_cannot_save_pipeline(self, mock_get_pipeline, mock_get_es, client, test_connection):
+ """Test that readonly user cannot save pipelines"""
+ from django.contrib.auth.models import User
+ from Management.models import UserProfile
+
+ readonly_user = User.objects.create_user(
+ username='readonly_save',
+ password='testpass123',
+ is_staff=False
+ )
+ readonly_user.profile.role = 'readonly'
+ readonly_user.profile.save()
+ client.login(username='readonly_save', password='testpass123')
+
+ mock_get_pipeline.return_value = {
+ 'pipeline': 'input {}\nfilter {}\noutput {}',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ 'pipeline_settings': {},
+ 'description': ''
+ }
+
+ mock_es = MagicMock()
+ mock_es.logstash.get_pipeline.return_value = {
+ 'test_pipeline': {
+ 'pipeline': 'input {}\nfilter {}\noutput {}',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ 'pipeline_settings': {},
+ 'description': ''
+ }
+ }
+ mock_get_es.return_value = mock_es
+
+ components = {"input": [], "filter": [], "output": []}
+
+ response = client.post('/ConnectionManager/SavePipeline/', {
+ 'save_pipeline': 'true',
+ 'es_id': test_connection.id,
+ 'pipeline': 'test_pipeline',
+ 'components': json.dumps(components)
+ })
+
+ assert response.status_code == 403
+
+ @patch('PipelineManager.manager_views.get_elastic_connection')
+ def test_readonly_user_cannot_clone_pipeline(self, mock_get_es, client, test_connection):
+ """Test that readonly user cannot clone pipelines"""
+ from django.contrib.auth.models import User
+ from Management.models import UserProfile
+
+ readonly_user = User.objects.create_user(
+ username='readonly_clone',
+ password='testpass123',
+ is_staff=False
+ )
+ readonly_user.profile.role = 'readonly'
+ readonly_user.profile.save()
+ client.login(username='readonly_clone', password='testpass123')
+
+ response = client.post('/ConnectionManager/ClonePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': 'source',
+ 'new_pipeline': 'cloned'
+ })
+
+ assert response.status_code == 403
+
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
+ def test_readonly_user_can_view_pipeline_editor(self, mock_get_pipeline, client, test_connection):
+ """Test that readonly user can view pipeline editor (read-only)"""
+ from django.contrib.auth.models import User
+ from Management.models import UserProfile
+
+ readonly_user = User.objects.create_user(
+ username='readonly_view',
+ password='testpass123',
+ is_staff=False
+ )
+ readonly_user.profile.role = 'readonly'
+ readonly_user.profile.save()
+ client.login(username='readonly_view', password='testpass123')
+
+ mock_get_pipeline.return_value = {
+ 'pipeline': 'input {}\nfilter {}\noutput {}',
+ 'pipeline_settings': {},
+ 'description': ''
+ }
+
+ response = client.get(
+ f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=test_pipeline'
+ )
+
+ # PipelineEditor doesn't have @require_admin_role, so readonly can view
+ assert response.status_code == 200
+
+
+# ============================================================================
+# PipelineEditor page
+# ============================================================================
+
+@pytest.mark.django_db
+class TestPipelineEditorPage:
+ """Tests for the PipelineEditor GET view"""
+
+ def test_missing_params_returns_400(self, authenticated_client):
+ """GET without es_id or pipeline returns 400"""
+ response = authenticated_client.get('/ConnectionManager/Pipelines/Editor/')
+ assert response.status_code == 400
+
+ def test_missing_pipeline_param_returns_400(self, authenticated_client, test_connection):
+ response = authenticated_client.get(
+ f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}'
+ )
+ assert response.status_code == 400
+
+ @patch('PipelineManager.editor_views.get_logstash_pipeline', return_value=None)
+ def test_pipeline_not_found_returns_400(self, mock_glp, authenticated_client, test_connection):
+ """When pipeline fetch returns None, view returns 400"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=nope'
+ )
+ assert response.status_code == 400
+
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
+ def test_successful_load_200(self, mock_glp, authenticated_client, test_connection):
+ mock_glp.return_value = {
+ 'pipeline': 'input {} filter {} output {}',
+ 'pipeline_settings': {},
+ 'description': 'test',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ }
+ response = authenticated_client.get(
+ f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=mypipe'
+ )
+ assert response.status_code == 200
+
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
+ def test_parse_error_captured_in_context(self, mock_glp, authenticated_client, test_connection):
+ """If config parsing fails, parsing_error is set in context (no 500)"""
+ mock_glp.return_value = {
+ 'pipeline': '<<< INVALID >>>',
+ 'pipeline_settings': {},
+ 'description': '',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ }
+ response = authenticated_client.get(
+ f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}&pipeline=bad'
+ )
+ assert response.status_code == 200
+ assert response.context.get('parsing_error') is not None
+
+
+# ============================================================================
+# GetCurrentPipelineCode endpoint
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetCurrentPipelineCode:
+ """Tests for the GetCurrentPipelineCode view"""
+
+ def test_returns_html_pre_block(self, authenticated_client):
+ components = {"input": [], "filter": [], "output": []}
+ response = authenticated_client.post(
+ '/ConnectionManager/GetCurrentPipelineCode/',
+ {'components': json.dumps(components)}
+ )
+ assert response.status_code == 200
+ assert b'alert' not in content
+
+ @patch('PipelineManager.manager_views.test_connectivity')
+ def test_readonly_user_cannot_add_connection(self, mock_test_connectivity, client, test_user):
+ """Test that readonly (non-admin) user cannot add connections"""
+ # Mock connectivity test
+ mock_test_connectivity.return_value = (True, "Connection successful")
+
+ # Create a readonly user (not admin)
+ from django.contrib.auth.models import User
+ from Management.models import UserProfile
+
+ readonly_user = User.objects.create_user(
+ username='readonly_add',
+ password='testpass123',
+ is_staff=False
+ )
+ # Update the auto-created profile to readonly role
+ # (post_save signal creates profile with 'admin' role by default)
+ readonly_user.profile.role = 'readonly'
+ readonly_user.profile.save()
+ client.login(username='readonly_add', password='testpass123')
+
+ response = client.post('/ConnectionManager/AddConnection', {
+ 'name': 'Test Connection',
+ 'connection_type': 'CENTRALIZED',
+ 'host': 'https://localhost:9200',
+ 'username': 'elastic',
+ 'password': 'changeme'
+ })
+
+ # Should be forbidden (403) due to @require_admin_role decorator
+ assert response.status_code == 403
+
+ def test_readonly_user_cannot_delete_connection(self, client, test_connection):
+ """Test that readonly (non-admin) user cannot delete connections"""
+ # Create a readonly user (not admin)
+ from django.contrib.auth.models import User
+ from Management.models import UserProfile
+
+ readonly_user = User.objects.create_user(
+ username='readonly_delete',
+ password='testpass123',
+ is_staff=False
+ )
+ # Update the auto-created profile to readonly role
+ # (post_save signal creates profile with 'admin' role by default)
+ readonly_user.profile.role = 'readonly'
+ readonly_user.profile.save()
+ client.login(username='readonly_delete', password='testpass123')
+
+ response = client.post(f'/ConnectionManager/DeleteConnection/{test_connection.id}/')
+
+ # Should be forbidden (403) due to @require_admin_role decorator
+ assert response.status_code == 403
+
+ # Verify connection was NOT deleted
+ assert Connection.objects.filter(id=test_connection.id).exists()
+
+
+# ============================================================================
+# test_connectivity() pure function
+# ============================================================================
+
+class TestConnectivityHelper:
+ """Unit tests for the test_connectivity() pure helper function"""
+
+ def test_no_connection_id_returns_false(self):
+ """Empty connection_id immediately returns (False, message)"""
+ from PipelineManager.manager_views import test_connectivity
+ success, msg = test_connectivity("")
+ assert success is False
+ assert "No connection ID" in msg
+
+ @patch('PipelineManager.manager_views.get_elastic_connection')
+ @patch('PipelineManager.manager_views.test_elastic_connectivity')
+ def test_success_returns_true_and_result(self, mock_test_elastic, mock_get_es):
+ from PipelineManager.manager_views import test_connectivity
+ mock_get_es.return_value = MagicMock()
+ mock_test_elastic.return_value = "Connected!"
+ success, msg = test_connectivity("42")
+ assert success is True
+ assert msg == "Connected!"
+
+ @patch('PipelineManager.manager_views.get_elastic_connection', side_effect=Exception("timeout"))
+ def test_exception_returns_false(self, mock_get_es):
+ from PipelineManager.manager_views import test_connectivity
+ success, msg = test_connectivity("42")
+ assert success is False
+ assert "timeout" in msg
+
+
+# ============================================================================
+# TestConnectivity VIEW — additional paths
+# ============================================================================
+
+@pytest.mark.django_db
+class TestTestConnectivityView:
+ """Tests for the TestConnectivity view"""
+
+ def test_no_test_id_returns_400(self, authenticated_client):
+ """GET without `test` param returns 400"""
+ response = authenticated_client.get('/ConnectionManager/TestConnectivity')
+ assert response.status_code == 400
+ assert b'No connection ID' in response.content
+
+ @patch('PipelineManager.manager_views.test_connectivity', return_value=(True, "All good!"))
+ def test_success_renders_green_div(self, mock_tc, authenticated_client, test_connection):
+ """Successful connection renders a green-coloured div"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/TestConnectivity?test={test_connection.id}'
+ )
+ assert response.status_code == 200
+ content = response.content.decode()
+ assert 'green' in content
+ assert 'All good!' in content
+
+
+# ============================================================================
+# GetConnections VIEW
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetConnections:
+ """Tests for the GetConnections view"""
+
+ def test_returns_json_list(self, authenticated_client, test_connection):
+ """Returns a JSON list of connection dicts"""
+ response = authenticated_client.get('/ConnectionManager/GetConnections/')
+ assert response.status_code == 200
+ data = response.json()
+ assert isinstance(data, list)
+ # At least our test_connection
+ ids = [c['id'] for c in data]
+ assert test_connection.id in ids
+
+ def test_returns_expected_fields(self, authenticated_client, test_connection):
+ """Each connection dict has id, name, connection_type"""
+ response = authenticated_client.get('/ConnectionManager/GetConnections/')
+ item = response.json()[0]
+ assert 'id' in item
+ assert 'name' in item
+ assert 'connection_type' in item
+
+
+# ============================================================================
+# AddConnection / DeleteConnection method guards
+# ============================================================================
+
+@pytest.mark.django_db
+class TestConnectionMethodGuards:
+ """Test HTTP method enforcement on connection endpoints"""
+
+ def test_add_connection_get_returns_405(self, authenticated_client):
+ """AddConnection only accepts POST — GET returns 405"""
+ response = authenticated_client.get('/ConnectionManager/AddConnection')
+ assert response.status_code == 405
+
+ def test_delete_connection_get_returns_405(self, authenticated_client, test_connection):
+ """DeleteConnection only accepts POST — GET returns 405"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/DeleteConnection/{test_connection.id}/'
+ )
+ assert response.status_code == 405
+
+
+# ============================================================================
+# PipelineManager page
+# ============================================================================
+
+@pytest.mark.django_db
+class TestPipelineManagerPage:
+ """Tests for the PipelineManager view"""
+
+ def test_page_loads(self, authenticated_client):
+ response = authenticated_client.get('/ConnectionManager/')
+ assert response.status_code == 200
+
+ def test_context_has_connections(self, authenticated_client, test_connection):
+ response = authenticated_client.get('/ConnectionManager/')
+ assert response.status_code == 200
+ assert 'connections' in response.context
+ assert 'has_connections' in response.context
+ assert response.context['has_connections'] is True
+
+
+# ============================================================================
+# GetPipeline endpoint
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPipelineEndpoint:
+ """Tests for the GetPipeline JSON view"""
+
+ def test_missing_params_returns_400(self, authenticated_client):
+ response = authenticated_client.get('/ConnectionManager/GetPipeline/')
+ assert response.status_code == 400
+ assert 'error' in response.json()
+
+ def test_missing_pipeline_returns_400(self, authenticated_client, test_connection):
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}'
+ )
+ assert response.status_code == 400
+
+ @patch('PipelineManager.pipelines_crud.get_logstash_pipeline', return_value=None)
+ def test_pipeline_not_found_returns_400(self, mock_glp, authenticated_client, test_connection):
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=missing'
+ )
+ assert response.status_code == 400
+ assert 'error' in response.json()
+
+ @patch('PipelineManager.pipelines_crud.get_logstash_pipeline')
+ def test_success_returns_code(self, mock_glp, authenticated_client, test_connection):
+ mock_glp.return_value = {'pipeline': 'input {} filter {} output {}'}
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=mypipe'
+ )
+ assert response.status_code == 200
+ assert response.json()['code'] == 'input {} filter {} output {}'
+
+
+# ============================================================================
+# ClonePipeline error paths
+# ============================================================================
+
+@pytest.mark.django_db
+class TestClonePipelineEdgeCases:
+ """Tests for ClonePipeline error paths"""
+
+ def test_invalid_source_name_returns_400(self, authenticated_client, test_connection):
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': '123bad',
+ 'new_pipeline': 'newpipe',
+ })
+ assert response.status_code == 400
+
+ def test_invalid_new_name_returns_400(self, authenticated_client, test_connection):
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': 'valid_source',
+ 'new_pipeline': '123bad',
+ })
+ assert response.status_code == 400
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_source_pipeline_not_found_returns_404(self, mock_get_es, authenticated_client, test_connection):
+ mock_es = MagicMock()
+ # get_pipeline returns dict that does NOT contain source_pipeline key
+ mock_es.logstash.get_pipeline.return_value = {}
+ mock_get_es.return_value = mock_es
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': 'missing_pipe',
+ 'new_pipeline': 'new_pipe',
+ })
+ assert response.status_code == 404
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_new_pipeline_name_already_exists_returns_400(self, mock_get_es, authenticated_client, test_connection):
+ mock_es = MagicMock()
+ mock_es.logstash.get_pipeline.side_effect = [
+ # First call: get source pipeline
+ {'source_pipe': {'pipeline': 'input {} filter {} output {}',
+ 'pipeline_settings': {}, 'description': ''}},
+ # Second call: get all pipelines — new_pipe already in there
+ {'source_pipe': {}, 'new_pipe': {}},
+ ]
+ mock_get_es.return_value = mock_es
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': 'source_pipe',
+ 'new_pipeline': 'new_pipe',
+ })
+ assert response.status_code == 400
+ assert b'already exists' in response.content
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection', side_effect=Exception("ES down"))
+ def test_clone_exception_returns_500(self, mock_get_es, authenticated_client, test_connection):
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': 'source_pipe',
+ 'new_pipeline': 'new_pipe',
+ })
+ assert response.status_code == 500
+
+
+# ============================================================================
+# DeletePipeline — additional paths
+# ============================================================================
+
+@pytest.mark.django_db
+class TestDeletePipelineEdgeCases:
+ """Extra DeletePipeline tests"""
+
+ def test_invalid_pipeline_name_returns_400(self, authenticated_client, test_connection):
+ response = authenticated_client.post('/ConnectionManager/DeletePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': '123invalid',
+ })
+ assert response.status_code == 400
+
+
+# ============================================================================
+# Integration Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestIntegration:
+ """Integration tests for complete workflows"""
+
+ @patch('PipelineManager.manager_views.test_connectivity')
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_full_pipeline_lifecycle(self, mock_get_es, mock_test_connectivity, authenticated_client):
+ """Test complete pipeline lifecycle: create connection, create pipeline, update, delete"""
+ # Step 1: Create connection
+ mock_test_connectivity.return_value = (True, "Connection successful")
+
+ conn_response = authenticated_client.post('/ConnectionManager/AddConnection', {
+ 'name': 'Integration Test Connection',
+ 'connection_type': 'CENTRALIZED',
+ 'host': 'https://localhost:9200',
+ 'username': 'elastic',
+ 'password': 'changeme'
+ })
+ assert conn_response.status_code == 200
+
+ connection = Connection.objects.get(name='Integration Test Connection')
+
+ # Step 2: Create pipeline
+ mock_es = MagicMock()
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_es.logstash.delete_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ create_response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': connection.id,
+ 'pipeline': 'integration_test_pipeline'
+ })
+ assert create_response.status_code == 200
+
+ # Step 3: Delete pipeline
+ delete_response = authenticated_client.post('/ConnectionManager/DeletePipeline/', {
+ 'es_id': connection.id,
+ 'pipeline': 'integration_test_pipeline'
+ })
+ assert delete_response.status_code == 204
+
+ # Step 4: Delete connection
+ delete_conn_response = authenticated_client.post(f'/ConnectionManager/DeleteConnection/{connection.id}/')
+ assert delete_conn_response.status_code == 200
+ assert not Connection.objects.filter(id=connection.id).exists()
+
+
+# ============================================================================
+# CreatePipeline — simulate path and default config
+# ============================================================================
+
+@pytest.mark.django_db
+class TestCreatePipelineAdditional:
+ """Additional CreatePipeline tests"""
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_creates_default_empty_config_when_no_pipeline_config(
+ self, mock_get_es, authenticated_client, test_connection):
+ """When no pipeline_config is given, the default 'input {} filter {} output {}' is used"""
+ mock_es = MagicMock()
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': 'default_pipe',
+ # no pipeline_config
+ })
+ assert response.status_code == 200
+ call_body = mock_es.logstash.put_pipeline.call_args[1]['body']
+ assert 'input {}' in call_body['pipeline']
+
+ @patch('PipelineManager.pipelines_crud.requests.put')
+ def test_simulate_mode_success(self, mock_put, authenticated_client, settings):
+ """CreatePipeline in simulate=True mode sends a PUT to logstashagent"""
+ settings.LOGSTASH_AGENT_URL = 'http://localhost:8080'
+ mock_response = MagicMock()
+ mock_response.raise_for_status.return_value = None
+ mock_put.return_value = mock_response
+
+ from PipelineManager.pipelines_crud import CreatePipeline
+ from django.test import RequestFactory
+ from django.contrib.auth.models import User
+
+ rf = RequestFactory()
+ user = User.objects.get(username='testuser')
+ request = rf.get('/')
+ request.user = user
+
+ response = CreatePipeline(
+ request,
+ simulate=True,
+ pipeline_name='sim_pipe',
+ pipeline_config='input {} filter {} output {}'
+ )
+ assert response.status_code == 200
+ assert b'Simulation pipeline created successfully' in response.content
+
+ @patch('PipelineManager.pipelines_crud.requests.put',
+ side_effect=__import__('requests').exceptions.ConnectionError("agent down"))
+ def test_simulate_mode_failure_returns_500(self, mock_put, authenticated_client, settings):
+ """CreatePipeline simulate=True with agent failure returns 500.
+
+ The view only catches requests.exceptions.RequestException — a generic
+ Exception would propagate uncaught, so we use a concrete subclass here.
+ """
+ settings.LOGSTASH_AGENT_URL = 'http://localhost:8080'
+
+ from PipelineManager.pipelines_crud import CreatePipeline
+ from django.test import RequestFactory
+ from django.contrib.auth.models import User
+
+ rf = RequestFactory()
+ user = User.objects.get(username='testuser')
+ request = rf.get('/')
+ request.user = user
+
+ response = CreatePipeline(
+ request,
+ simulate=True,
+ pipeline_name='sim_pipe',
+ pipeline_config='input {} filter {} output {}'
+ )
+ assert response.status_code == 500
diff --git a/LogstashUI/PipelineManager/tests/test_pipeline_editor.py b/src/logstashui/PipelineManager/tests/test_pipeline_editor.py
similarity index 94%
rename from LogstashUI/PipelineManager/tests/test_pipeline_editor.py
rename to src/logstashui/PipelineManager/tests/test_pipeline_editor.py
index 1594ec72..5ce40d5c 100644
--- a/LogstashUI/PipelineManager/tests/test_pipeline_editor.py
+++ b/src/logstashui/PipelineManager/tests/test_pipeline_editor.py
@@ -20,7 +20,7 @@
class TestPipelineEditorView:
"""Test PipelineEditor view"""
- @patch('PipelineManager.views.get_logstash_pipeline')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
def test_pipeline_editor_success(self, mock_get_pipeline, authenticated_client, test_connection):
"""Test successful pipeline editor load"""
mock_get_pipeline.return_value = {
@@ -44,22 +44,22 @@ def test_pipeline_editor_success(self, mock_get_pipeline, authenticated_client,
assert b'test_pipeline' in response.content
assert b'Test pipeline' in response.content
- @patch('PipelineManager.views.get_logstash_pipeline')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
def test_pipeline_editor_missing_es_id(self, mock_get_pipeline, authenticated_client):
"""Test PipelineEditor with missing es_id parameter"""
response = authenticated_client.get('/ConnectionManager/Pipelines/Editor/?pipeline=test_pipeline')
assert response.status_code == 400
assert b'Missing required parameters' in response.content
- @patch('PipelineManager.views.get_logstash_pipeline')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
def test_pipeline_editor_missing_pipeline_param(self, mock_get_pipeline, authenticated_client, test_connection):
"""Test PipelineEditor with missing pipeline parameter"""
response = authenticated_client.get(f'/ConnectionManager/Pipelines/Editor/?es_id={test_connection.id}')
assert response.status_code == 400
assert b'Missing required parameters' in response.content
- @patch('PipelineManager.views.get_logstash_pipeline')
- @patch('PipelineManager.views.logstash_config_parse.logstash_config_to_components')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
+ @patch('PipelineManager.editor_views.logstash_config_parse.logstash_config_to_components')
def test_pipeline_editor_with_parsing_error(self, mock_parse, mock_get_pipeline, authenticated_client, test_connection):
"""Test pipeline editor when parsing fails"""
mock_get_pipeline.return_value = {
@@ -86,7 +86,7 @@ def test_pipeline_editor_with_parsing_error(self, mock_parse, mock_get_pipeline,
class TestGetPipeline:
"""Test GetPipeline view"""
- @patch('PipelineManager.views.get_logstash_pipeline')
+ @patch('PipelineManager.pipelines_crud.get_logstash_pipeline')
def test_get_pipeline_success(self, mock_get_pipeline, authenticated_client, test_connection):
"""Test successful pipeline retrieval"""
pipeline_config = 'input { stdin {} }\nfilter { mutate { add_field => { "test" => "value" } } }\noutput { stdout {} }'
@@ -170,7 +170,7 @@ def test_components_to_config_invalid_json(self, authenticated_client):
assert response.status_code == 500
assert b'Error' in response.content
- @patch('PipelineManager.views.logstash_config_parse.logstash_config_to_components')
+ @patch('PipelineManager.editor_views.logstash_config_parse.logstash_config_to_components')
def test_config_to_components_success(self, mock_parse, authenticated_client):
"""Test successful config to components conversion"""
config_text = 'input { stdin {} }\nfilter {}\noutput { stdout {} }'
@@ -202,7 +202,7 @@ def test_config_to_components_no_config(self, authenticated_client):
assert 'error' in data
assert 'No config text provided' in data['error']
- @patch('PipelineManager.views.logstash_config_parse.logstash_config_to_components')
+ @patch('PipelineManager.editor_views.logstash_config_parse.logstash_config_to_components')
def test_config_to_components_parse_error(self, mock_parse, authenticated_client):
"""Test ConfigToComponents with parsing error"""
mock_parse.side_effect = Exception("Invalid syntax")
@@ -274,7 +274,7 @@ def test_components_config_roundtrip(self, authenticated_client):
class TestGetDiff:
"""Test GetDiff view"""
- @patch('PipelineManager.views.get_logstash_pipeline')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
def test_get_diff_with_matching_configs(self, mock_get_pipeline, authenticated_client, test_connection):
"""Test GetDiff when configs are identical"""
current_config = 'input {}\nfilter {}\noutput {}'
@@ -302,7 +302,7 @@ def test_get_diff_with_matching_configs(self, mock_get_pipeline, authenticated_c
assert 'filter' in data['current'] and 'filter' in data['new']
assert 'output' in data['current'] and 'output' in data['new']
- @patch('PipelineManager.views.get_logstash_pipeline')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
def test_get_diff_with_different_configs(self, mock_get_pipeline, authenticated_client, test_connection):
"""Test GetDiff when configs differ"""
current_config = 'input {}\nfilter {}\noutput {}'
@@ -334,7 +334,7 @@ def test_get_diff_with_different_configs(self, mock_get_pipeline, authenticated_
# Should show addition of stdin input
assert 'stdin' in data['new']
- @patch('PipelineManager.views.get_logstash_pipeline')
+ @patch('PipelineManager.editor_views.get_logstash_pipeline')
def test_get_diff_with_text_mode(self, mock_get_pipeline, authenticated_client, test_connection):
"""Test GetDiff using raw pipeline text instead of components"""
current_config = 'input {}\nfilter {}\noutput {}'
@@ -441,7 +441,7 @@ def test_get_current_pipeline_code_mutable_default_safety(self, authenticated_cl
class TestClonePipeline:
"""Test ClonePipeline view"""
- @patch('PipelineManager.views.get_elastic_connection')
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
def test_clone_pipeline_success(self, mock_get_es, authenticated_client, test_connection):
"""Test successful pipeline cloning"""
mock_es = MagicMock()
@@ -470,10 +470,11 @@ def test_clone_pipeline_success(self, mock_get_es, authenticated_client, test_co
})
assert response.status_code == 200
- # Should contain script to close modal and refresh
- assert b'clonePipelineModal' in response.content or b'script' in response.content
+ assert b'Pipeline cloned successfully!' in response.content
+ # Should have HX-Trigger header for HTMX
+ assert 'HX-Trigger' in response
- @patch('PipelineManager.views.get_elastic_connection')
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
def test_clone_pipeline_duplicate_name(self, mock_get_es, authenticated_client, test_connection):
"""Test cloning with duplicate pipeline name"""
mock_es = MagicMock()
@@ -528,7 +529,7 @@ def test_clone_pipeline_invalid_new_name(self, authenticated_client, test_connec
assert response.status_code == 400
assert b'Pipeline' in response.content and (b'invalid' in response.content.lower() or b'error' in response.content.lower())
- @patch('PipelineManager.views.get_elastic_connection')
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
def test_clone_pipeline_source_not_found(self, mock_get_es, authenticated_client, test_connection):
"""Test cloning when source pipeline doesn't exist"""
mock_es = MagicMock()
diff --git a/src/logstashui/PipelineManager/tests/test_pipelines_crud.py b/src/logstashui/PipelineManager/tests/test_pipelines_crud.py
new file mode 100644
index 00000000..e94304bc
--- /dev/null
+++ b/src/logstashui/PipelineManager/tests/test_pipelines_crud.py
@@ -0,0 +1,775 @@
+#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+#or more contributor license agreements. Licensed under the Elastic License;
+#you may not use this file except in compliance with the Elastic License.
+
+from Common.test_resources import authenticated_client, test_connection, test_user
+from PipelineManager.models import Connection, Policy, Pipeline
+
+from unittest.mock import patch, MagicMock
+import json
+import pytest
+
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+@pytest.fixture
+def test_policy(db):
+ """Create a test policy"""
+ policy = Policy.objects.create(
+ name='Test Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "0.0.0.0"',
+ jvm_options='-Xms1g\n-Xmx1g',
+ log4j2_properties='logger.logstash.name = logstash'
+ )
+ return policy
+
+
+@pytest.fixture
+def test_pipeline(db, test_policy):
+ """Create a test pipeline"""
+ pipeline = Pipeline.objects.create(
+ policy=test_policy,
+ name='test_pipeline',
+ lscl='input { beats { port => 5044 } } filter {} output { elasticsearch { hosts => ["localhost:9200"] } }',
+ description='Test pipeline description'
+ )
+ return pipeline
+
+
+# ============================================================================
+# CreatePipeline Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestCreatePipeline:
+ """Test Pipeline Create operations"""
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_create_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection):
+ """Test successful pipeline creation for centralized connection"""
+ # Mock Elasticsearch connection
+ mock_es = MagicMock()
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': 'test_pipeline',
+ 'pipeline_config': 'input {}\nfilter {}\noutput {}'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline created successfully!' in response.content
+ assert 'HX-Redirect' in response
+
+ # Verify put_pipeline was called
+ mock_es.logstash.put_pipeline.assert_called_once()
+ call_args = mock_es.logstash.put_pipeline.call_args
+ assert call_args[1]['id'] == 'test_pipeline'
+ assert call_args[1]['body']['pipeline'] == 'input {}\nfilter {}\noutput {}'
+
+ def test_create_pipeline_agent_success(self, authenticated_client, test_policy):
+ """Test successful pipeline creation for agent policy"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'policy_id': test_policy.id,
+ 'pipeline': 'new_pipeline',
+ 'pipeline_config': 'input {}\nfilter {}\noutput {}'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline created successfully!' in response.content
+ assert 'HX-Redirect' in response
+
+ # Verify pipeline was created
+ assert Pipeline.objects.filter(policy=test_policy, name='new_pipeline').exists()
+
+ # Verify policy marked as changed
+ test_policy.refresh_from_db()
+ assert test_policy.has_undeployed_changes is True
+
+ def test_create_pipeline_agent_duplicate_name(self, authenticated_client, test_policy, test_pipeline):
+ """Test creating pipeline with duplicate name in agent policy"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'policy_id': test_policy.id,
+ 'pipeline': 'test_pipeline', # Already exists
+ 'pipeline_config': 'input {}\nfilter {}\noutput {}'
+ })
+
+ assert response.status_code == 400
+ assert b'already exists' in response.content
+
+ def test_create_pipeline_invalid_name(self, authenticated_client, test_connection):
+ """Test pipeline creation with invalid name"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': '123invalid', # Can't start with number
+ })
+
+ assert response.status_code == 400
+ assert b'Pipeline ID must begin with a letter or underscore' in response.content
+
+ def test_create_pipeline_empty_name(self, authenticated_client, test_connection):
+ """Test pipeline creation with empty name"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': '',
+ })
+
+ assert response.status_code == 400
+ assert b'Pipeline name cannot be empty' in response.content
+
+ def test_create_pipeline_nonexistent_policy(self, authenticated_client):
+ """Test creating pipeline for non-existent policy"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'policy_id': 99999,
+ 'pipeline': 'test_pipeline',
+ 'pipeline_config': 'input {}\nfilter {}\noutput {}'
+ })
+
+ assert response.status_code == 404
+ assert b'not found' in response.content
+
+ def test_create_pipeline_no_context(self, authenticated_client):
+ """Test creating pipeline without es_id or policy_id"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'pipeline': 'test_pipeline',
+ 'pipeline_config': 'input {}\nfilter {}\noutput {}'
+ })
+
+ assert response.status_code == 400
+ assert b'neither policy_id nor es_id provided' in response.content
+
+
+# ============================================================================
+# DeletePipeline Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestDeletePipeline:
+ """Test Pipeline Delete operations"""
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_delete_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection):
+ """Test successful pipeline deletion for centralized connection"""
+ # Mock Elasticsearch connection
+ mock_es = MagicMock()
+ mock_es.logstash.delete_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/DeletePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': 'test_pipeline'
+ })
+
+ assert response.status_code == 204
+
+ # Verify delete_pipeline was called
+ mock_es.logstash.delete_pipeline.assert_called_once_with(id='test_pipeline')
+
+ def test_delete_pipeline_agent_success(self, authenticated_client, test_policy, test_pipeline):
+ """Test successful pipeline deletion for agent policy"""
+ response = authenticated_client.post('/ConnectionManager/DeletePipeline/', {
+ 'policy_id': test_policy.id,
+ 'pipeline': 'test_pipeline'
+ })
+
+ assert response.status_code == 204
+
+ # Verify pipeline was deleted
+ assert not Pipeline.objects.filter(policy=test_policy, name='test_pipeline').exists()
+
+ # Verify policy marked as changed
+ test_policy.refresh_from_db()
+ assert test_policy.has_undeployed_changes is True
+
+ def test_delete_pipeline_agent_json_format(self, authenticated_client, test_policy, test_pipeline):
+ """Test deleting pipeline with JSON request body"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeletePipeline/',
+ data=json.dumps({
+ 'policy_id': test_policy.id,
+ 'pipeline': 'test_pipeline'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 204
+
+ # Verify pipeline was deleted
+ assert not Pipeline.objects.filter(policy=test_policy, name='test_pipeline').exists()
+
+ def test_delete_pipeline_invalid_name(self, authenticated_client, test_connection):
+ """Test deleting pipeline with invalid name"""
+ response = authenticated_client.post('/ConnectionManager/DeletePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': '123invalid'
+ })
+
+ assert response.status_code == 400
+ assert b'must begin with a letter or underscore' in response.content
+
+ def test_delete_pipeline_nonexistent_agent(self, authenticated_client, test_policy):
+ """Test deleting non-existent pipeline from agent policy"""
+ response = authenticated_client.post('/ConnectionManager/DeletePipeline/', {
+ 'policy_id': test_policy.id,
+ 'pipeline': 'nonexistent'
+ })
+
+ assert response.status_code == 404
+ assert b'not found' in response.content
+
+
+# ============================================================================
+# UpdatePipelineSettings Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestUpdatePipelineSettings:
+ """Test Pipeline Settings Update operations"""
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ @patch('PipelineManager.pipelines_crud.get_logstash_pipeline')
+ def test_update_pipeline_settings_centralized_success(self, mock_get_pipeline, mock_get_es, authenticated_client, test_connection):
+ """Test successful pipeline settings update for centralized connection"""
+ # Mock existing pipeline
+ mock_get_pipeline.return_value = {
+ 'pipeline': 'input {}\nfilter {}\noutput {}',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ 'pipeline_settings': {},
+ 'description': ''
+ }
+
+ # Mock Elasticsearch connection
+ mock_es = MagicMock()
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', {
+ 'es_id': test_connection.id,
+ 'pipeline': 'test_pipeline',
+ 'description': 'Updated description',
+ 'pipeline_workers': '2',
+ 'pipeline_batch_size': '250'
+ })
+
+ assert response.status_code == 200
+
+ # Verify put_pipeline was called with updated settings
+ mock_es.logstash.put_pipeline.assert_called()
+ call_args = mock_es.logstash.put_pipeline.call_args
+ assert call_args[1]['body']['description'] == 'Updated description'
+ assert call_args[1]['body']['pipeline_settings']['pipeline.workers'] == 2
+ assert call_args[1]['body']['pipeline_settings']['pipeline.batch.size'] == 250
+
+ def test_update_pipeline_settings_agent_success(self, authenticated_client, test_policy, test_pipeline):
+ """Test successful pipeline settings update for agent policy"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', {
+ 'ls_id': test_policy.id,
+ 'pipeline': 'test_pipeline',
+ 'description': 'Updated description',
+ 'pipeline_workers': '4',
+ 'pipeline_batch_size': '512'
+ })
+
+ assert response.status_code == 200
+
+ # Verify pipeline was updated
+ test_pipeline.refresh_from_db()
+ assert test_pipeline.description == 'Updated description'
+ assert test_pipeline.pipeline_workers == 4
+ assert test_pipeline.pipeline_batch_size == 512
+
+ # Verify policy marked as changed
+ test_policy.refresh_from_db()
+ assert test_policy.has_undeployed_changes is True
+
+ def test_update_pipeline_settings_queue_settings(self, authenticated_client, test_policy, test_pipeline):
+ """Test updating queue settings"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', {
+ 'ls_id': test_policy.id,
+ 'pipeline': 'test_pipeline',
+ 'queue_type': 'persisted',
+ 'queue_max_bytes': '10',
+ 'queue_max_bytes_unit': 'gb',
+ 'queue_checkpoint_writes': '2048'
+ })
+
+ assert response.status_code == 200
+
+ # Verify pipeline was updated
+ test_pipeline.refresh_from_db()
+ assert test_pipeline.queue_type == 'persisted'
+ assert test_pipeline.queue_max_bytes == '10gb'
+ assert test_pipeline.queue_checkpoint_writes == 2048
+
+ def test_update_pipeline_settings_missing_pipeline_id(self, authenticated_client):
+ """Test updating settings without pipeline ID or connection ID"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', {
+ 'pipeline': 'test_pipeline',
+ 'description': 'Updated'
+ })
+
+ assert response.status_code == 400
+ assert b'Missing pipeline ID or connection ID' in response.content
+
+ def test_update_pipeline_settings_invalid_name(self, authenticated_client, test_connection):
+ """Test updating settings with invalid pipeline name"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', {
+ 'es_id': test_connection.id,
+ 'pipeline': '123invalid'
+ })
+
+ assert response.status_code == 400
+ assert b'must begin with a letter or underscore' in response.content
+
+ def test_update_pipeline_settings_nonexistent_agent(self, authenticated_client, test_policy):
+ """Test updating settings for non-existent agent pipeline"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineSettings/', {
+ 'ls_id': test_policy.id,
+ 'pipeline': 'nonexistent',
+ 'description': 'Test'
+ })
+
+ assert response.status_code == 404
+ assert b'not found' in response.content
+
+ def test_update_pipeline_settings_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/UpdatePipelineSettings/')
+
+ assert response.status_code == 405
+ assert b'Invalid request method' in response.content
+
+
+# ============================================================================
+# ClonePipeline Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestClonePipeline:
+ """Test Pipeline Clone operations"""
+
+ def test_clone_pipeline_agent_success(self, authenticated_client, test_policy, test_pipeline):
+ """Test successful pipeline cloning for agent policy"""
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'test_pipeline',
+ 'new_pipeline': 'cloned_pipeline'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline cloned successfully!' in response.content
+ assert 'HX-Trigger' in response
+
+ # Verify cloned pipeline was created
+ cloned = Pipeline.objects.get(policy=test_policy, name='cloned_pipeline')
+ assert cloned.lscl == test_pipeline.lscl
+ assert cloned.description == 'Cloned from test_pipeline'
+
+ # Verify policy marked as changed
+ test_policy.refresh_from_db()
+ assert test_policy.has_undeployed_changes is True
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_clone_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection):
+ """Test successful pipeline cloning for centralized connection"""
+ # Mock Elasticsearch connection
+ mock_es = MagicMock()
+ mock_es.logstash.get_pipeline.return_value = {
+ 'source_pipeline': {
+ 'pipeline': 'input {}\nfilter {}\noutput {}',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ 'pipeline_settings': {'pipeline.workers': 2},
+ 'description': 'Source pipeline'
+ }
+ }
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': 'source_pipeline',
+ 'new_pipeline': 'cloned_pipeline'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline cloned successfully!' in response.content
+
+ # Verify put_pipeline was called
+ mock_es.logstash.put_pipeline.assert_called_once()
+ call_args = mock_es.logstash.put_pipeline.call_args
+ assert call_args[1]['id'] == 'cloned_pipeline'
+
+ def test_clone_pipeline_duplicate_name(self, authenticated_client, test_policy, test_pipeline):
+ """Test cloning pipeline with duplicate name"""
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'test_pipeline',
+ 'new_pipeline': 'test_pipeline' # Same name
+ })
+
+ assert response.status_code == 400
+ assert b'already exists' in response.content
+
+ def test_clone_pipeline_invalid_source_name(self, authenticated_client, test_policy):
+ """Test cloning with invalid source pipeline name"""
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': '123invalid',
+ 'new_pipeline': 'new_pipeline'
+ })
+
+ assert response.status_code == 400
+ assert b'Invalid source pipeline name' in response.content
+
+ def test_clone_pipeline_invalid_new_name(self, authenticated_client, test_policy, test_pipeline):
+ """Test cloning with invalid new pipeline name"""
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'test_pipeline',
+ 'new_pipeline': '123invalid'
+ })
+
+ assert response.status_code == 400
+ assert b'must begin with a letter or underscore' in response.content
+
+ def test_clone_pipeline_nonexistent_source(self, authenticated_client, test_policy):
+ """Test cloning non-existent source pipeline"""
+ response = authenticated_client.post('/ConnectionManager/ClonePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'nonexistent',
+ 'new_pipeline': 'new_pipeline'
+ })
+
+ assert response.status_code == 404
+ assert b'not found' in response.content
+
+
+# ============================================================================
+# RenamePipeline Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestRenamePipeline:
+ """Test Pipeline Rename operations"""
+
+ def test_rename_pipeline_agent_success(self, authenticated_client, test_policy, test_pipeline):
+ """Test successful pipeline renaming for agent policy"""
+ response = authenticated_client.post('/ConnectionManager/RenamePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'test_pipeline',
+ 'new_pipeline': 'renamed_pipeline'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline renamed successfully!' in response.content
+ assert 'HX-Trigger' in response
+
+ # Verify renamed pipeline exists
+ assert Pipeline.objects.filter(policy=test_policy, name='renamed_pipeline').exists()
+
+ # Verify original pipeline was deleted
+ assert not Pipeline.objects.filter(policy=test_policy, name='test_pipeline').exists()
+
+ # Verify policy marked as changed
+ test_policy.refresh_from_db()
+ assert test_policy.has_undeployed_changes is True
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_rename_pipeline_centralized_success(self, mock_get_es, authenticated_client, test_connection):
+ """Test successful pipeline renaming for centralized connection"""
+ # Mock Elasticsearch connection
+ mock_es = MagicMock()
+ mock_es.logstash.get_pipeline.return_value = {
+ 'source_pipeline': {
+ 'pipeline': 'input {}\nfilter {}\noutput {}',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ 'pipeline_settings': {'pipeline.workers': 2},
+ 'description': 'Source pipeline'
+ }
+ }
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_es.logstash.delete_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/RenamePipeline/', {
+ 'es_id': test_connection.id,
+ 'source_pipeline': 'source_pipeline',
+ 'new_pipeline': 'renamed_pipeline'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline renamed successfully!' in response.content
+
+ # Verify put_pipeline and delete_pipeline were called
+ mock_es.logstash.put_pipeline.assert_called_once()
+ mock_es.logstash.delete_pipeline.assert_called_once_with(id='source_pipeline')
+
+ def test_rename_pipeline_duplicate_name(self, authenticated_client, test_policy):
+ """Test renaming pipeline to existing name"""
+ # Create two pipelines
+ Pipeline.objects.create(
+ policy=test_policy,
+ name='pipeline1',
+ lscl='input {} filter {} output {}'
+ )
+ Pipeline.objects.create(
+ policy=test_policy,
+ name='pipeline2',
+ lscl='input {} filter {} output {}'
+ )
+
+ response = authenticated_client.post('/ConnectionManager/RenamePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'pipeline1',
+ 'new_pipeline': 'pipeline2' # Already exists
+ })
+
+ assert response.status_code == 400
+ assert b'already exists' in response.content
+
+ def test_rename_pipeline_invalid_source_name(self, authenticated_client, test_policy):
+ """Test renaming with invalid source pipeline name"""
+ response = authenticated_client.post('/ConnectionManager/RenamePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': '123invalid',
+ 'new_pipeline': 'new_pipeline'
+ })
+
+ assert response.status_code == 400
+ assert b'Invalid source pipeline name' in response.content
+
+ def test_rename_pipeline_invalid_new_name(self, authenticated_client, test_policy, test_pipeline):
+ """Test renaming with invalid new pipeline name"""
+ response = authenticated_client.post('/ConnectionManager/RenamePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'test_pipeline',
+ 'new_pipeline': '123invalid'
+ })
+
+ assert response.status_code == 400
+ assert b'must begin with a letter or underscore' in response.content
+
+ def test_rename_pipeline_nonexistent_source(self, authenticated_client, test_policy):
+ """Test renaming non-existent source pipeline"""
+ response = authenticated_client.post('/ConnectionManager/RenamePipeline/', {
+ 'policy_id': test_policy.id,
+ 'source_pipeline': 'nonexistent',
+ 'new_pipeline': 'new_pipeline'
+ })
+
+ assert response.status_code == 404
+ assert b'not found' in response.content
+
+
+# ============================================================================
+# UpdatePipelineDescription Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestUpdatePipelineDescription:
+ """Test Pipeline Description Update operations"""
+
+ def test_update_description_agent_success(self, authenticated_client, test_policy, test_pipeline):
+ """Test successful description update for agent policy"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', {
+ 'policy_id': test_policy.id,
+ 'pipeline_name': 'test_pipeline',
+ 'description': 'Updated description'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline description updated successfully!' in response.content
+ assert 'HX-Trigger' in response
+
+ # Verify description was updated
+ test_pipeline.refresh_from_db()
+ assert test_pipeline.description == 'Updated description'
+
+ # Verify policy marked as changed
+ test_policy.refresh_from_db()
+ assert test_policy.has_undeployed_changes is True
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_update_description_centralized_success(self, mock_get_es, authenticated_client, test_connection):
+ """Test successful description update for centralized connection"""
+ # Mock Elasticsearch connection
+ mock_es = MagicMock()
+ mock_es.logstash.get_pipeline.return_value = {
+ 'test_pipeline': {
+ 'pipeline': 'input {}\nfilter {}\noutput {}',
+ 'pipeline_metadata': {'version': 1, 'type': 'logstash_pipeline'},
+ 'pipeline_settings': {},
+ 'description': 'Old description'
+ }
+ }
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', {
+ 'es_id': test_connection.id,
+ 'pipeline_name': 'test_pipeline',
+ 'description': 'New description'
+ })
+
+ assert response.status_code == 200
+ assert b'Pipeline description updated successfully!' in response.content
+
+ # Verify put_pipeline was called with new description
+ mock_es.logstash.put_pipeline.assert_called_once()
+ call_args = mock_es.logstash.put_pipeline.call_args
+ assert call_args[1]['body']['description'] == 'New description'
+
+ def test_update_description_invalid_name(self, authenticated_client, test_policy):
+ """Test updating description with invalid pipeline name"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', {
+ 'policy_id': test_policy.id,
+ 'pipeline_name': '123invalid',
+ 'description': 'Test'
+ })
+
+ assert response.status_code == 400
+ assert b'Invalid pipeline name' in response.content
+
+ def test_update_description_nonexistent_agent(self, authenticated_client, test_policy):
+ """Test updating description for non-existent agent pipeline"""
+ response = authenticated_client.post('/ConnectionManager/UpdatePipelineDescription/', {
+ 'policy_id': test_policy.id,
+ 'pipeline_name': 'nonexistent',
+ 'description': 'Test'
+ })
+
+ assert response.status_code == 404
+ assert b'not found' in response.content
+
+
+# ============================================================================
+# GetPipeline Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPipeline:
+ """Test GetPipeline endpoint"""
+
+ @patch('PipelineManager.pipelines_crud.get_logstash_pipeline')
+ def test_get_pipeline_success(self, mock_get_pipeline, authenticated_client, test_connection):
+ """Test successful pipeline retrieval"""
+ mock_get_pipeline.return_value = {
+ 'pipeline': 'input {}\nfilter {}\noutput {}'
+ }
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=test_pipeline'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert 'code' in data
+ assert data['code'] == 'input {}\nfilter {}\noutput {}'
+
+ def test_get_pipeline_missing_es_id(self, authenticated_client):
+ """Test getting pipeline without es_id"""
+ response = authenticated_client.get('/ConnectionManager/GetPipeline/?pipeline=test_pipeline')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert 'error' in data
+ assert 'Missing required parameters' in data['error']
+
+ def test_get_pipeline_missing_pipeline_name(self, authenticated_client, test_connection):
+ """Test getting pipeline without pipeline name"""
+ response = authenticated_client.get(f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert 'error' in data
+ assert 'Missing required parameters' in data['error']
+
+ @patch('Common.logstash_utils.get_logstash_pipeline')
+ def test_get_pipeline_not_found(self, mock_get_pipeline, authenticated_client, test_connection):
+ """Test getting non-existent pipeline"""
+ mock_get_pipeline.return_value = None
+
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetPipeline/?es_id={test_connection.id}&pipeline=nonexistent'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert 'error' in data
+ assert 'Could not fetch pipeline' in data['error']
+
+
+# ============================================================================
+# Pipeline Name Validation Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestPipelineNameValidation:
+ """Test pipeline name validation"""
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_pipeline_name_starts_with_letter(self, mock_get_es, authenticated_client, test_connection):
+ """Test that pipeline name can start with a letter"""
+ mock_es = MagicMock()
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': 'valid_pipeline'
+ })
+
+ assert response.status_code == 200
+
+ @patch('PipelineManager.pipelines_crud.get_elastic_connection')
+ def test_pipeline_name_starts_with_underscore(self, mock_get_es, authenticated_client, test_connection):
+ """Test that pipeline name can start with underscore"""
+ mock_es = MagicMock()
+ mock_es.logstash.put_pipeline.return_value = {'acknowledged': True}
+ mock_get_es.return_value = mock_es
+
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': '_valid_pipeline'
+ })
+
+ assert response.status_code == 200
+
+ def test_pipeline_name_starts_with_number_invalid(self, authenticated_client, test_connection):
+ """Test that pipeline name cannot start with a number"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': '123invalid'
+ })
+
+ assert response.status_code == 400
+ assert b'must begin with a letter or underscore' in response.content
+
+ def test_pipeline_name_special_chars_invalid(self, authenticated_client, test_connection):
+ """Test that pipeline name cannot contain special characters"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': 'invalid@pipeline'
+ })
+
+ assert response.status_code == 400
+ # Pipeline name starts with a letter but contains invalid @ character
+ # Check for the actual error message from the validator
+ assert (b'can only contain letters' in response.content or
+ b'underscores, dashes, hyphens' in response.content)
+
+ def test_pipeline_name_empty_invalid(self, authenticated_client, test_connection):
+ """Test that pipeline name cannot be empty"""
+ response = authenticated_client.post('/ConnectionManager/CreatePipeline/', {
+ 'es_id': test_connection.id,
+ 'pipeline': ''
+ })
+
+ assert response.status_code == 400
+ assert b'Pipeline name cannot be empty' in response.content
diff --git a/src/logstashui/PipelineManager/tests/test_policies_crud.py b/src/logstashui/PipelineManager/tests/test_policies_crud.py
new file mode 100644
index 00000000..23c3ca68
--- /dev/null
+++ b/src/logstashui/PipelineManager/tests/test_policies_crud.py
@@ -0,0 +1,860 @@
+#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+#or more contributor license agreements. Licensed under the Elastic License;
+#you may not use this file except in compliance with the Elastic License.
+
+from Common.test_resources import authenticated_client, test_user
+from PipelineManager.models import Connection, Policy, Pipeline, Keystore, EnrollmentToken
+
+from unittest.mock import patch
+import json
+import pytest
+
+
+# ============================================================================
+# Fixtures
+# ============================================================================
+
+@pytest.fixture
+def test_policy(db):
+ """Create a test policy"""
+ policy = Policy.objects.create(
+ name='Test Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "0.0.0.0"',
+ jvm_options='-Xms1g\n-Xmx1g',
+ log4j2_properties='logger.logstash.name = logstash'
+ )
+ return policy
+
+
+@pytest.fixture
+def test_policy_with_pipelines(db):
+ """Create a test policy with pipelines"""
+ policy = Policy.objects.create(
+ name='Policy With Pipelines',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin',
+ logstash_yml='http.host: "0.0.0.0"',
+ jvm_options='-Xms1g\n-Xmx1g',
+ log4j2_properties='logger.logstash.name = logstash'
+ )
+
+ # Create pipelines
+ Pipeline.objects.create(
+ policy=policy,
+ name='pipeline1',
+ lscl='input {} filter {} output {}'
+ )
+ Pipeline.objects.create(
+ policy=policy,
+ name='pipeline2',
+ lscl='input {} filter {} output {}'
+ )
+
+ # Create keystore entries
+ Keystore.objects.create(
+ policy=policy,
+ key_name='key1',
+ key_value='value1'
+ )
+
+ return policy
+
+
+@pytest.fixture
+def test_enrollment_token(db, test_policy):
+ """Create a test enrollment token"""
+ token = EnrollmentToken.objects.create(
+ policy=test_policy,
+ name='test_token',
+ token='test_token_value_123'
+ )
+ return token
+
+
+# ============================================================================
+# GetPolicies Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetPolicies:
+ """Tests for the get_policies endpoint"""
+
+ def test_get_policies_success(self, authenticated_client, test_policy):
+ """Test successful retrieval of policies"""
+ response = authenticated_client.get('/ConnectionManager/GetPolicies/')
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'policies' in data
+ assert len(data['policies']) >= 1
+
+ # Find our test policy
+ policy_names = [p['name'] for p in data['policies']]
+ assert 'Test Policy' in policy_names
+
+ def test_get_policies_includes_connection_count(self, authenticated_client, test_policy):
+ """Test that policies include connection count"""
+ # Create an agent connection
+ Connection.objects.create(
+ name='Test Agent',
+ connection_type='AGENT',
+ host='agent.example.com',
+ agent_id='test-001',
+ is_active=True,
+ policy=test_policy
+ )
+
+ response = authenticated_client.get('/ConnectionManager/GetPolicies/')
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # Find our test policy
+ test_policy_data = next(p for p in data['policies'] if p['name'] == 'Test Policy')
+ assert test_policy_data['connection_count'] == 1
+
+ def test_get_policies_empty(self, authenticated_client):
+ """Test getting policies when none exist"""
+ Policy.objects.all().delete()
+
+ response = authenticated_client.get('/ConnectionManager/GetPolicies/')
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['policies']) == 0
+
+
+# ============================================================================
+# AddPolicy Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestAddPolicy:
+ """Tests for the add_policy endpoint"""
+
+ def test_add_policy_success(self, authenticated_client):
+ """Test successful policy creation"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddPolicy/',
+ data=json.dumps({
+ 'name': 'New Policy',
+ 'settings_path': '/etc/logstash/',
+ 'logs_path': '/var/log/logstash',
+ 'binary_path': '/usr/share/logstash/bin',
+ 'logstash_yml': 'http.host: "0.0.0.0"',
+ 'jvm_options': '-Xms1g\n-Xmx1g',
+ 'log4j2_properties': 'logger.logstash.name = logstash'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'policy_id' in data
+ assert data['policy_name'] == 'New Policy'
+
+ # Verify policy was created
+ assert Policy.objects.filter(name='New Policy').exists()
+
+ # Verify enrollment token was created
+ policy = Policy.objects.get(name='New Policy')
+ assert EnrollmentToken.objects.filter(policy=policy, name='default').exists()
+
+ def test_add_policy_with_defaults(self, authenticated_client):
+ """Test policy creation with default values"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddPolicy/',
+ data=json.dumps({
+ 'name': 'Minimal Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify policy was created with defaults
+ policy = Policy.objects.get(name='Minimal Policy')
+ assert policy.settings_path == '/etc/logstash/'
+ assert policy.logs_path == '/var/log/logstash'
+ assert policy.binary_path == '/usr/share/logstash/bin'
+ assert len(policy.logstash_yml) > 0 # Should have default config
+ assert len(policy.jvm_options) > 0
+ assert len(policy.log4j2_properties) > 0
+
+ def test_add_policy_missing_name(self, authenticated_client):
+ """Test policy creation without name"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddPolicy/',
+ data=json.dumps({}),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy name is required' in data['error']
+
+ def test_add_policy_duplicate_name(self, authenticated_client, test_policy):
+ """Test creating policy with duplicate name"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddPolicy/',
+ data=json.dumps({
+ 'name': 'Test Policy' # Already exists
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'already exists' in data['error']
+
+ def test_add_policy_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/AddPolicy/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_add_policy_invalid_json(self, authenticated_client):
+ """Test policy creation with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddPolicy/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# UpdatePolicy Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestUpdatePolicy:
+ """Tests for the update_policy endpoint"""
+
+ def test_update_policy_success(self, authenticated_client, test_policy):
+ """Test successful policy update"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdatePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Test Policy',
+ 'settings_path': '/new/settings',
+ 'logstash_yml': 'http.host: "127.0.0.1"'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify policy was updated
+ test_policy.refresh_from_db()
+ assert test_policy.settings_path == '/new/settings'
+ assert test_policy.logstash_yml == 'http.host: "127.0.0.1"'
+
+ def test_update_policy_partial_update(self, authenticated_client, test_policy):
+ """Test updating only some fields"""
+ original_logs_path = test_policy.logs_path
+
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdatePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Test Policy',
+ 'settings_path': '/updated/settings'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+
+ # Verify only specified field was updated
+ test_policy.refresh_from_db()
+ assert test_policy.settings_path == '/updated/settings'
+ assert test_policy.logs_path == original_logs_path # Unchanged
+
+ def test_update_policy_missing_name(self, authenticated_client):
+ """Test updating policy without name"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdatePolicy/',
+ data=json.dumps({
+ 'settings_path': '/new/settings'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy name is required' in data['error']
+
+ def test_update_policy_nonexistent(self, authenticated_client):
+ """Test updating non-existent policy"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdatePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Nonexistent Policy',
+ 'settings_path': '/new/settings'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'not found' in data['error']
+
+ def test_update_policy_default_policy_forbidden(self, authenticated_client):
+ """Test that Default Policy cannot be updated"""
+ # Create Default Policy
+ Policy.objects.create(
+ name='Default Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin'
+ )
+
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdatePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Default Policy',
+ 'settings_path': '/new/settings'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 403
+ data = response.json()
+ assert data['success'] is False
+ assert 'Cannot update Default Policy' in data['error']
+
+ def test_update_policy_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/UpdatePolicy/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_update_policy_invalid_json(self, authenticated_client):
+ """Test policy update with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/UpdatePolicy/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# DeletePolicy Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestDeletePolicy:
+ """Tests for the delete_policy endpoint"""
+
+ def test_delete_policy_success(self, authenticated_client, test_policy):
+ """Test successful policy deletion"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeletePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Test Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify policy was deleted
+ assert not Policy.objects.filter(name='Test Policy').exists()
+
+ def test_delete_policy_missing_name(self, authenticated_client):
+ """Test deleting policy without name"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeletePolicy/',
+ data=json.dumps({}),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy name is required' in data['error']
+
+ def test_delete_policy_nonexistent(self, authenticated_client):
+ """Test deleting non-existent policy"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeletePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Nonexistent Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'not found' in data['error']
+
+ def test_delete_policy_default_policy_forbidden(self, authenticated_client):
+ """Test that Default Policy cannot be deleted"""
+ # Create Default Policy
+ Policy.objects.create(
+ name='Default Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin'
+ )
+
+ response = authenticated_client.post(
+ '/ConnectionManager/DeletePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Default Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 403
+ data = response.json()
+ assert data['success'] is False
+ assert 'Cannot delete Default Policy' in data['error']
+
+ def test_delete_policy_in_use(self, authenticated_client, test_policy):
+ """Test that policy in use cannot be deleted"""
+ # Create a connection using this policy
+ Connection.objects.create(
+ name='Test Agent',
+ connection_type='AGENT',
+ host='agent.example.com',
+ agent_id='test-001',
+ is_active=True,
+ policy=test_policy
+ )
+
+ response = authenticated_client.post(
+ '/ConnectionManager/DeletePolicy/',
+ data=json.dumps({
+ 'policy_name': 'Test Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'currently assigned to' in data['error']
+
+ # Verify policy was not deleted
+ assert Policy.objects.filter(name='Test Policy').exists()
+
+ def test_delete_policy_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/DeletePolicy/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_delete_policy_invalid_json(self, authenticated_client):
+ """Test policy deletion with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeletePolicy/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# ClonePolicy Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestClonePolicy:
+ """Tests for the clone_policy endpoint"""
+
+ def test_clone_policy_success(self, authenticated_client, test_policy_with_pipelines):
+ """Test successful policy cloning"""
+ response = authenticated_client.post(
+ '/ConnectionManager/ClonePolicy/',
+ data=json.dumps({
+ 'source_policy_id': test_policy_with_pipelines.id,
+ 'new_policy_name': 'Cloned Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'policy_id' in data
+ assert data['policy_name'] == 'Cloned Policy'
+
+ # Verify new policy was created
+ cloned_policy = Policy.objects.get(name='Cloned Policy')
+ assert cloned_policy.settings_path == test_policy_with_pipelines.settings_path
+ assert cloned_policy.logstash_yml == test_policy_with_pipelines.logstash_yml
+
+ # Verify pipelines were cloned
+ assert Pipeline.objects.filter(policy=cloned_policy).count() == 2
+ assert Pipeline.objects.filter(policy=cloned_policy, name='pipeline1').exists()
+ assert Pipeline.objects.filter(policy=cloned_policy, name='pipeline2').exists()
+
+ # Verify keystore entries were cloned
+ assert Keystore.objects.filter(policy=cloned_policy).count() == 1
+ assert Keystore.objects.filter(policy=cloned_policy, key_name='key1').exists()
+
+ # Verify enrollment token was created
+ assert EnrollmentToken.objects.filter(policy=cloned_policy, name='default').exists()
+
+ def test_clone_policy_missing_source_id(self, authenticated_client):
+ """Test cloning without source policy ID"""
+ response = authenticated_client.post(
+ '/ConnectionManager/ClonePolicy/',
+ data=json.dumps({
+ 'new_policy_name': 'Cloned Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Source policy ID is required' in data['error']
+
+ def test_clone_policy_missing_new_name(self, authenticated_client, test_policy):
+ """Test cloning without new policy name"""
+ response = authenticated_client.post(
+ '/ConnectionManager/ClonePolicy/',
+ data=json.dumps({
+ 'source_policy_id': test_policy.id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'New policy name is required' in data['error']
+
+ def test_clone_policy_duplicate_name(self, authenticated_client, test_policy):
+ """Test cloning to existing policy name"""
+ # Create another policy
+ Policy.objects.create(
+ name='Existing Policy',
+ settings_path='/etc/logstash/',
+ logs_path='/var/log/logstash',
+ binary_path='/usr/share/logstash/bin'
+ )
+
+ response = authenticated_client.post(
+ '/ConnectionManager/ClonePolicy/',
+ data=json.dumps({
+ 'source_policy_id': test_policy.id,
+ 'new_policy_name': 'Existing Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'already exists' in data['error']
+
+ def test_clone_policy_nonexistent_source(self, authenticated_client):
+ """Test cloning non-existent source policy"""
+ response = authenticated_client.post(
+ '/ConnectionManager/ClonePolicy/',
+ data=json.dumps({
+ 'source_policy_id': 99999,
+ 'new_policy_name': 'Cloned Policy'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Source policy not found' in data['error']
+
+ def test_clone_policy_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/ClonePolicy/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_clone_policy_invalid_json(self, authenticated_client):
+ """Test policy cloning with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/ClonePolicy/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# GetEnrollmentTokens Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestGetEnrollmentTokens:
+ """Tests for the get_enrollment_tokens endpoint"""
+
+ def test_get_enrollment_tokens_success(self, authenticated_client, test_policy, test_enrollment_token):
+ """Test successful retrieval of enrollment tokens"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetEnrollmentTokens/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['tokens']) >= 1
+
+ # Verify token data
+ token_data = next(t for t in data['tokens'] if t['name'] == 'test_token')
+ assert token_data['raw_token'] == 'test_token_value_123'
+ assert 'encoded_token' in token_data
+
+ def test_get_enrollment_tokens_empty(self, authenticated_client, test_policy):
+ """Test getting tokens when none exist"""
+ response = authenticated_client.get(
+ f'/ConnectionManager/GetEnrollmentTokens/?policy_id={test_policy.id}'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert len(data['tokens']) == 0
+
+ def test_get_enrollment_tokens_missing_policy_id(self, authenticated_client):
+ """Test getting tokens without policy_id"""
+ response = authenticated_client.get('/ConnectionManager/GetEnrollmentTokens/')
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_get_enrollment_tokens_nonexistent_policy(self, authenticated_client):
+ """Test getting tokens for non-existent policy"""
+ response = authenticated_client.get('/ConnectionManager/GetEnrollmentTokens/?policy_id=99999')
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+
+# ============================================================================
+# AddEnrollmentToken Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestAddEnrollmentToken:
+ """Tests for the add_enrollment_token endpoint"""
+
+ def test_add_enrollment_token_success(self, authenticated_client, test_policy):
+ """Test successful enrollment token creation"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddEnrollmentToken/',
+ data=json.dumps({
+ 'policy_id': test_policy.id,
+ 'name': 'new_token'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+ assert 'token_id' in data
+
+ # Verify token was created
+ assert EnrollmentToken.objects.filter(policy=test_policy, name='new_token').exists()
+
+ def test_add_enrollment_token_default_name(self, authenticated_client, test_policy):
+ """Test token creation with default name"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddEnrollmentToken/',
+ data=json.dumps({
+ 'policy_id': test_policy.id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify token was created with default name
+ assert EnrollmentToken.objects.filter(policy=test_policy, name='default').exists()
+
+ def test_add_enrollment_token_missing_policy_id(self, authenticated_client):
+ """Test token creation without policy_id"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddEnrollmentToken/',
+ data=json.dumps({
+ 'name': 'new_token'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy ID is required' in data['error']
+
+ def test_add_enrollment_token_nonexistent_policy(self, authenticated_client):
+ """Test token creation for non-existent policy"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddEnrollmentToken/',
+ data=json.dumps({
+ 'policy_id': 99999,
+ 'name': 'new_token'
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Policy not found' in data['error']
+
+ def test_add_enrollment_token_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/AddEnrollmentToken/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_add_enrollment_token_invalid_json(self, authenticated_client):
+ """Test token creation with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/AddEnrollmentToken/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
+
+
+# ============================================================================
+# DeleteEnrollmentToken Tests
+# ============================================================================
+
+@pytest.mark.django_db
+class TestDeleteEnrollmentToken:
+ """Tests for the delete_enrollment_token endpoint"""
+
+ def test_delete_enrollment_token_success(self, authenticated_client, test_enrollment_token):
+ """Test successful enrollment token deletion"""
+ token_id = test_enrollment_token.id
+
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteEnrollmentToken/',
+ data=json.dumps({
+ 'token_id': token_id
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data['success'] is True
+
+ # Verify token was deleted
+ assert not EnrollmentToken.objects.filter(id=token_id).exists()
+
+ def test_delete_enrollment_token_missing_token_id(self, authenticated_client):
+ """Test deleting token without token_id"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteEnrollmentToken/',
+ data=json.dumps({}),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Token ID is required' in data['error']
+
+ def test_delete_enrollment_token_nonexistent(self, authenticated_client):
+ """Test deleting non-existent token"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteEnrollmentToken/',
+ data=json.dumps({
+ 'token_id': 99999
+ }),
+ content_type='application/json'
+ )
+
+ assert response.status_code == 404
+ data = response.json()
+ assert data['success'] is False
+ assert 'Enrollment token not found' in data['error']
+
+ def test_delete_enrollment_token_wrong_method(self, authenticated_client):
+ """Test that GET requests are rejected"""
+ response = authenticated_client.get('/ConnectionManager/DeleteEnrollmentToken/')
+
+ assert response.status_code == 405
+ data = response.json()
+ assert data['success'] is False
+ assert 'Method not allowed' in data['error']
+
+ def test_delete_enrollment_token_invalid_json(self, authenticated_client):
+ """Test token deletion with invalid JSON"""
+ response = authenticated_client.post(
+ '/ConnectionManager/DeleteEnrollmentToken/',
+ data='not valid json',
+ content_type='application/json'
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert data['success'] is False
+ assert 'Invalid JSON data' in data['error']
diff --git a/LogstashUI/PipelineManager/tests/test_simulation.py b/src/logstashui/PipelineManager/tests/test_simulation.py
similarity index 99%
rename from LogstashUI/PipelineManager/tests/test_simulation.py
rename to src/logstashui/PipelineManager/tests/test_simulation.py
index 5a522f62..b77448d0 100644
--- a/LogstashUI/PipelineManager/tests/test_simulation.py
+++ b/src/logstashui/PipelineManager/tests/test_simulation.py
@@ -445,7 +445,7 @@ def test_check_pipeline_loaded_no_pipeline_name(self, authenticated_client):
@patch('PipelineManager.simulation.requests.get')
def test_check_pipeline_loaded_service_unavailable(self, mock_get, authenticated_client):
- """Test CheckIfPipelineLoaded when LogstashAgent is unavailable"""
+ """Test CheckIfPipelineLoaded when logstashagent is unavailable"""
mock_get.side_effect = Exception("Connection refused")
response = authenticated_client.get('/ConnectionManager/CheckIfPipelineLoaded/?pipeline_name=slot1-filter1')
@@ -538,7 +538,7 @@ def test_get_related_logs_with_filters(self, mock_get, authenticated_client):
@patch('PipelineManager.simulation.requests.get')
def test_get_related_logs_service_unavailable(self, mock_get, authenticated_client):
- """Test GetRelatedLogs when LogstashAgent is unavailable"""
+ """Test GetRelatedLogs when logstashagent is unavailable"""
mock_get.side_effect = Exception("Connection refused")
response = authenticated_client.get('/ConnectionManager/GetRelatedLogs/?slot_id=1')
@@ -634,7 +634,7 @@ def test_upload_file_oversized(self, mock_post, authenticated_client):
@patch('PipelineManager.simulation.requests.post')
def test_upload_file_agent_failure(self, mock_post, authenticated_client):
- """Test UploadFile when LogstashAgent fails"""
+ """Test UploadFile when logstashagent fails"""
mock_post.side_effect = Exception("Connection refused")
file_content = b'test content'
diff --git a/src/logstashui/PipelineManager/urls.py b/src/logstashui/PipelineManager/urls.py
new file mode 100644
index 00000000..673b03d6
--- /dev/null
+++ b/src/logstashui/PipelineManager/urls.py
@@ -0,0 +1,90 @@
+#Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+#or more contributor license agreements. Licensed under the Elastic License;
+#you may not use this file except in compliance with the Elastic License.
+
+from django.urls import path
+from . import agent_api, simulation, manager_views, editor_views, policies_crud, agent_policies, connections_crud, \
+ pipelines_crud
+
+urlpatterns = [
+
+ path("", manager_views.PipelineManager, name="PipelineManager"),
+ path("Pipelines/Editor/", editor_views.PipelineEditor, name="PipelineEditor"),
+ path("AgentPolicies/", manager_views.AgentPolicies, name="AgentPolicies"),
+
+ path("SimulatePipeline/", simulation.SimulatePipeline, name="SimulatePipeline"),
+ path("StreamSimulate/", simulation.StreamSimulate, name="StreamSimulate"),
+ path("GetSimulationResults/", simulation.GetSimulationResults, name="GetSimulationResults"),
+ path("CheckIfPipelineLoaded/", simulation.CheckIfPipelineLoaded, name="CheckIfPipelineLoaded"),
+ path("GetRelatedLogs/", simulation.GetRelatedLogs, name="GetRelatedLogs"),
+ path("UploadFile/", simulation.UploadFile, name="UploadFile"),
+ path("GetSimulationNodeStatus/", simulation.GetSimulationNodeStatus, name="GetSimulationNodeStatus"),
+ path("GetSimulationNodeHealth/", simulation.GetSimulationNodeHealth, name="GetSimulationNodeHealth"),
+ path("ValidateLogstashConfig/", simulation.ValidateLogstashConfig, name="ValidateLogstashConfig"),
+
+ path('TestConnectivity', manager_views.TestConnectivity, name='TestConnectivity'),
+
+ path("GetConnections/", connections_crud.GetConnections, name="GetConnections"),
+ path("AddConnection", connections_crud.AddConnection, name="AddConnection"),
+ path("DeleteConnection//", connections_crud.DeleteConnection, name="DeleteConnection"),
+ path("UpgradeAgent//", connections_crud.UpgradeAgent, name="UpgradeAgent"),
+ path("ChangeConnectionPolicy/", connections_crud.change_connection_policy, name="ChangeConnectionPolicy"),
+ path("RestartLogstash/", connections_crud.restart_logstash, name="RestartLogstash"),
+ path("GetPipelines//", connections_crud.GetPipelines, name="GetPipelines"),
+ path("GetPolicyPipelines/", connections_crud.GetPolicyPipelines, name="GetPolicyPipelines"),
+
+ path("GetPolicies/", policies_crud.get_policies, name="GetPolicies"),
+ path("AddPolicy/", policies_crud.add_policy, name="AddPolicy"),
+ path("UpdatePolicy/", policies_crud.update_policy, name="UpdatePolicy"),
+ path("DeletePolicy/", policies_crud.delete_policy, name="DeletePolicy"),
+ path("ClonePolicy/", policies_crud.clone_policy, name="ClonePolicy"),
+ path("GetEnrollmentTokens/", policies_crud.get_enrollment_tokens, name="GetEnrollmentTokens"),
+ path("AddEnrollmentToken/", policies_crud.add_enrollment_token, name="AddEnrollmentToken"),
+ path("DeleteEnrollmentToken/", policies_crud.delete_enrollment_token, name="DeleteEnrollmentToken"),
+
+ path("GetPolicyDiff/", agent_policies.get_policy_diff, name="GetPolicyDiff"),
+ path("GetPolicyAgentCount/", agent_policies.get_policy_agent_count, name="GetPolicyAgentCount"),
+ path("GetPolicyChangeCount/", agent_policies.get_policy_change_count, name="GetPolicyChangeCount"),
+ path("DeployPolicy/", agent_policies.deploy_policy, name="DeployPolicy"),
+ path("GenerateEnrollmentToken/", agent_policies.generate_enrollment_token, name="GenerateEnrollmentToken"),
+
+ path("Enroll/", agent_api.enroll, name="Enroll"),
+ path("CheckIn/", agent_api.check_in, name="CheckIn"),
+ path("GetConfigChanges/", agent_api.get_config_changes, name="GetConfigChanges"),
+
+ path("GetKeystoreEntries/", agent_policies.get_keystore_entries, name="GetKeystoreEntries"),
+ path("CreateKeystoreEntry/", agent_policies.create_keystore_entry, name="CreateKeystoreEntry"),
+ path("UpdateKeystoreEntry/", agent_policies.update_keystore_entry, name="UpdateKeystoreEntry"),
+ path("DeleteKeystoreEntry/", agent_policies.delete_keystore_entry, name="DeleteKeystoreEntry"),
+ path("SetKeystorePassword/", agent_policies.set_keystore_password, name="SetKeystorePassword"),
+
+ path("GetCurrentPipelineCode/", editor_views.GetCurrentPipelineCode, name="GetCurrentPipelineCode"),
+ path("GetDiff/", editor_views.GetDiff, name="GetDiff"),
+ path("SavePipeline/", editor_views.SavePipeline, name="SavePipeline"),
+ path("ComponentsToConfig/", editor_views.ComponentsToConfig, name="ComponentsToConfig"),
+ path("ConfigToComponents/", editor_views.ConfigToComponents, name="ConfigToComponents"),
+
+ path("UpdatePipelineSettings/", pipelines_crud.UpdatePipelineSettings, name="UpdatePipelineSettings"),
+ path("CreatePipeline/", pipelines_crud.CreatePipeline, name="CreatePipeline"),
+ path("DeletePipeline/", pipelines_crud.DeletePipeline, name="DeletePipeline"),
+ path("ClonePipeline/", pipelines_crud.ClonePipeline, name="ClonePipeline"),
+ path("RenamePipeline/", pipelines_crud.RenamePipeline, name="RenamePipeline"),
+ path("UpdatePipelineDescription/", pipelines_crud.UpdatePipelineDescription, name="UpdatePipelineDescription"),
+ path("GetPipeline/", pipelines_crud.GetPipeline, name="GetPipeline"),
+
+ # Elasticsearch simulation endpoints
+ path("GetElasticsearchConnections/", editor_views.GetElasticsearchConnections, name="GetElasticsearchConnections"),
+ path("GetElasticsearchIndices/", editor_views.GetElasticsearchIndices, name="GetElasticsearchIndices"),
+ path("GetElasticsearchFields/", editor_views.GetElasticsearchFields, name="GetElasticsearchFields"),
+ path("QueryElasticsearchDocuments/", editor_views.QueryElasticsearchDocuments, name="QueryElasticsearchDocuments"),
+
+ # Plugin documentation endpoint
+ path("GetPluginDocumentation/", editor_views.GetPluginDocumentation, name="GetPluginDocumentation"),
+
+ # Agent inspect modal — fresh data on each open
+ path("AgentInspect//", manager_views.get_agent_inspect, name="AgentInspect"),
+
+ # SSE: real-time agent status stream
+ path("AgentStatusStream/", manager_views.agent_status_stream, name="AgentStatusStream")
+
+]
diff --git a/LogstashUI/Site/migrations/__init__.py b/src/logstashui/SNMP/__init__.py
similarity index 100%
rename from LogstashUI/Site/migrations/__init__.py
rename to src/logstashui/SNMP/__init__.py
diff --git a/LogstashUI/SNMP/apps.py b/src/logstashui/SNMP/apps.py
similarity index 100%
rename from LogstashUI/SNMP/apps.py
rename to src/logstashui/SNMP/apps.py
diff --git a/LogstashUI/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json b/src/logstashui/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json
rename to src/logstashui/SNMP/data/official_profiles/broadcom_fibre_channel_switch.json
diff --git a/LogstashUI/SNMP/data/official_profiles/cdp.json b/src/logstashui/SNMP/data/official_profiles/cdp.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/cdp.json
rename to src/logstashui/SNMP/data/official_profiles/cdp.json
diff --git a/LogstashUI/SNMP/data/official_profiles/cisco_sensors_and_fans.json b/src/logstashui/SNMP/data/official_profiles/cisco_sensors_and_fans.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/cisco_sensors_and_fans.json
rename to src/logstashui/SNMP/data/official_profiles/cisco_sensors_and_fans.json
diff --git a/LogstashUI/SNMP/data/official_profiles/cisco_system_metrics.json b/src/logstashui/SNMP/data/official_profiles/cisco_system_metrics.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/cisco_system_metrics.json
rename to src/logstashui/SNMP/data/official_profiles/cisco_system_metrics.json
diff --git a/LogstashUI/SNMP/data/official_profiles/dell_idrac.json b/src/logstashui/SNMP/data/official_profiles/dell_idrac.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/dell_idrac.json
rename to src/logstashui/SNMP/data/official_profiles/dell_idrac.json
diff --git a/LogstashUI/SNMP/data/official_profiles/generic_metrics.json b/src/logstashui/SNMP/data/official_profiles/generic_metrics.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/generic_metrics.json
rename to src/logstashui/SNMP/data/official_profiles/generic_metrics.json
diff --git a/LogstashUI/SNMP/data/official_profiles/hpe_nimble_san.json b/src/logstashui/SNMP/data/official_profiles/hpe_nimble_san.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/hpe_nimble_san.json
rename to src/logstashui/SNMP/data/official_profiles/hpe_nimble_san.json
diff --git a/LogstashUI/SNMP/data/official_profiles/lldp.json b/src/logstashui/SNMP/data/official_profiles/lldp.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/lldp.json
rename to src/logstashui/SNMP/data/official_profiles/lldp.json
diff --git a/LogstashUI/SNMP/data/official_profiles/network_interfaces.json b/src/logstashui/SNMP/data/official_profiles/network_interfaces.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/network_interfaces.json
rename to src/logstashui/SNMP/data/official_profiles/network_interfaces.json
diff --git a/LogstashUI/SNMP/data/official_profiles/system.json b/src/logstashui/SNMP/data/official_profiles/system.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/system.json
rename to src/logstashui/SNMP/data/official_profiles/system.json
diff --git a/LogstashUI/SNMP/data/official_profiles/traps.json b/src/logstashui/SNMP/data/official_profiles/traps.json
similarity index 100%
rename from LogstashUI/SNMP/data/official_profiles/traps.json
rename to src/logstashui/SNMP/data/official_profiles/traps.json
diff --git a/LogstashUI/SNMP/management/__init__.py b/src/logstashui/SNMP/management/__init__.py
similarity index 100%
rename from LogstashUI/SNMP/management/__init__.py
rename to src/logstashui/SNMP/management/__init__.py
diff --git a/LogstashUI/SNMP/management/commands/__init__.py b/src/logstashui/SNMP/management/commands/__init__.py
similarity index 100%
rename from LogstashUI/SNMP/management/commands/__init__.py
rename to src/logstashui/SNMP/management/commands/__init__.py
diff --git a/LogstashUI/SNMP/management/commands/load_test_snmp_data.py b/src/logstashui/SNMP/management/commands/load_test_snmp_data.py
similarity index 100%
rename from LogstashUI/SNMP/management/commands/load_test_snmp_data.py
rename to src/logstashui/SNMP/management/commands/load_test_snmp_data.py
diff --git a/LogstashUI/SNMP/migrations/0001_initial.py b/src/logstashui/SNMP/migrations/0001_initial.py
similarity index 98%
rename from LogstashUI/SNMP/migrations/0001_initial.py
rename to src/logstashui/SNMP/migrations/0001_initial.py
index 3360cf3e..06f7ab39 100644
--- a/LogstashUI/SNMP/migrations/0001_initial.py
+++ b/src/logstashui/SNMP/migrations/0001_initial.py
@@ -25,8 +25,8 @@ class Migration(migrations.Migration):
('security_level', models.CharField(blank=True, choices=[('noAuthNoPriv', 'No Authentication, No Privacy'), ('authNoPriv', 'Authentication, No Privacy'), ('authPriv', 'Authentication and Privacy')], help_text='SNMPv3 security level', max_length=20)),
('auth_protocol', models.CharField(blank=True, choices=[('md5', 'MD5'), ('sha', 'SHA'), ('sha2', 'SHA2'), ('hmac128sha224', 'HMAC128-SHA224'), ('hmac192sha256', 'HMAC192-SHA256'), ('hmac256sha384', 'HMAC256-SHA384'), ('hmac384sha512', 'HMAC384-SHA512')], help_text='SNMPv3 authentication protocol', max_length=20)),
('auth_pass', models.CharField(blank=True, help_text='SNMPv3 authentication passphrase or password', max_length=255)),
- ('priv_protocol', models.CharField(blank=True, choices=[('des', 'DES'), ('3des', '3DES'), ('aes', 'AES'), ('aes128', 'AES128'), ('aes192', 'AES192'), ('aes256', 'AES256'), ('aes256with3desKey', 'AES256 with 3DES Key')], help_text='SNMPv3 privacy/encryption protocol', max_length=20)),
- ('priv_pass', models.CharField(blank=True, help_text='SNMPv3 encryption password', max_length=255)),
+ ('priv_protocol', models.CharField(blank=True, choices=[('des', 'DES'), ('3des', '3DES'), ('aes', 'AES'), ('aes128', 'AES128'), ('aes192', 'AES192'), ('aes256', 'AES256'), ('aes256with3desKey', 'AES256 with 3DES Key')], help_text='SNMPv3 privacy/encryption.py protocol', max_length=20)),
+ ('priv_pass', models.CharField(blank=True, help_text='SNMPv3 encryption.py password', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
diff --git a/LogstashUI/Site/templatetags/__init__.py b/src/logstashui/SNMP/migrations/__init__.py
similarity index 100%
rename from LogstashUI/Site/templatetags/__init__.py
rename to src/logstashui/SNMP/migrations/__init__.py
diff --git a/LogstashUI/SNMP/models.py b/src/logstashui/SNMP/models.py
similarity index 99%
rename from LogstashUI/SNMP/models.py
rename to src/logstashui/SNMP/models.py
index d3dbf4d8..020e5990 100644
--- a/LogstashUI/SNMP/models.py
+++ b/src/logstashui/SNMP/models.py
@@ -293,13 +293,13 @@ class Credential(models.Model):
max_length=20,
choices=PRIV_PROTOCOL_CHOICES,
blank=True,
- help_text="SNMPv3 privacy/encryption protocol"
+ help_text="SNMPv3 privacy/encryption.py protocol"
)
priv_pass = models.CharField(
max_length=255,
blank=True,
- help_text="SNMPv3 encryption password"
+ help_text="SNMPv3 encryption.py password"
)
created_at = models.DateTimeField(auto_now_add=True)
diff --git a/LogstashUI/SNMP/snmp_crud.py b/src/logstashui/SNMP/snmp_crud.py
similarity index 99%
rename from LogstashUI/SNMP/snmp_crud.py
rename to src/logstashui/SNMP/snmp_crud.py
index a33282fc..f049dbe6 100644
--- a/LogstashUI/SNMP/snmp_crud.py
+++ b/src/logstashui/SNMP/snmp_crud.py
@@ -94,7 +94,7 @@ def AddCredential(request):
credential.priv_protocol = request.POST.get('priv_protocol')
credential.priv_pass = request.POST.get('priv_pass')
- # Save (this will trigger validation and encryption)
+ # Save (this will trigger validation and encryption.py)
credential.save()
return JsonResponse({'id': credential.id, 'message': 'Credential created successfully!'}, status=200)
@@ -151,7 +151,7 @@ def UpdateCredential(request, credential_id):
if priv_pass:
credential.priv_pass = priv_pass
- # Save (this will trigger validation and encryption)
+ # Save (this will trigger validation and encryption.py)
credential.save()
return JsonResponse({'id': credential.id, 'message': 'Credential updated successfully!'}, status=200)
@@ -291,7 +291,7 @@ def _create_or_update_pipeline(es_connection, pipeline_name, pipeline_content, d
pipeline_body = {
"pipeline": pipeline_content,
"last_modified": datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z',
- "username": "LogstashUI",
+ "username": "logstashui",
"description": description
}
@@ -2727,7 +2727,17 @@ def GetDiscoveredDevices(request):
if network_name:
try:
+ # Try exact match first
network_obj = Network.objects.filter(name=network_name).first()
+
+ # If no exact match, try case-insensitive match
+ if not network_obj:
+ network_obj = Network.objects.filter(name__iexact=network_name).first()
+
+ # If still no match, try case-insensitive contains (for cases like 'Homelab' -> 'homelab-segment1')
+ if not network_obj:
+ network_obj = Network.objects.filter(name__icontains=network_name).first()
+
if network_obj:
network_id = network_obj.id
if network_obj.discovery_credential:
diff --git a/LogstashUI/SNMP/static/js/devices_table.js b/src/logstashui/SNMP/static/js/devices_table.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/devices_table.js
rename to src/logstashui/SNMP/static/js/devices_table.js
diff --git a/LogstashUI/SNMP/static/js/discovered_devices.js b/src/logstashui/SNMP/static/js/discovered_devices.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/discovered_devices.js
rename to src/logstashui/SNMP/static/js/discovered_devices.js
diff --git a/LogstashUI/SNMP/static/js/networks_table.js b/src/logstashui/SNMP/static/js/networks_table.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/networks_table.js
rename to src/logstashui/SNMP/static/js/networks_table.js
diff --git a/LogstashUI/SNMP/static/js/snmp_commit_modal.js b/src/logstashui/SNMP/static/js/snmp_commit_modal.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/snmp_commit_modal.js
rename to src/logstashui/SNMP/static/js/snmp_commit_modal.js
diff --git a/LogstashUI/SNMP/static/js/snmp_credentials_modal.js b/src/logstashui/SNMP/static/js/snmp_credentials_modal.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/snmp_credentials_modal.js
rename to src/logstashui/SNMP/static/js/snmp_credentials_modal.js
diff --git a/LogstashUI/SNMP/static/js/snmp_device_visual_preview.js b/src/logstashui/SNMP/static/js/snmp_device_visual_preview.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/snmp_device_visual_preview.js
rename to src/logstashui/SNMP/static/js/snmp_device_visual_preview.js
diff --git a/LogstashUI/SNMP/static/js/snmp_devices_modal.js b/src/logstashui/SNMP/static/js/snmp_devices_modal.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/snmp_devices_modal.js
rename to src/logstashui/SNMP/static/js/snmp_devices_modal.js
diff --git a/LogstashUI/SNMP/static/js/snmp_diff_modal.js b/src/logstashui/SNMP/static/js/snmp_diff_modal.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/snmp_diff_modal.js
rename to src/logstashui/SNMP/static/js/snmp_diff_modal.js
diff --git a/LogstashUI/SNMP/static/js/snmp_network_modal.js b/src/logstashui/SNMP/static/js/snmp_network_modal.js
similarity index 98%
rename from LogstashUI/SNMP/static/js/snmp_network_modal.js
rename to src/logstashui/SNMP/static/js/snmp_network_modal.js
index aa436b6f..c0f478cf 100644
--- a/LogstashUI/SNMP/static/js/snmp_network_modal.js
+++ b/src/logstashui/SNMP/static/js/snmp_network_modal.js
@@ -77,7 +77,11 @@ function loadConnections(selectedConnectionId = null) {
connectionSelect.innerHTML = 'Select a connection... ';
connectionSelect.innerHTML += '+ Add Connection ';
- connections.forEach(connection => {
+ const centralizedConnections = connections.filter(connection =>
+ connection.connection_type === 'CENTRALIZED'
+ );
+
+ centralizedConnections.forEach(connection => {
const option = document.createElement('option');
option.value = connection.id;
option.textContent = `${connection.name} (${connection.connection_type})`;
diff --git a/LogstashUI/SNMP/static/js/snmp_profile_modal.js b/src/logstashui/SNMP/static/js/snmp_profile_modal.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/snmp_profile_modal.js
rename to src/logstashui/SNMP/static/js/snmp_profile_modal.js
diff --git a/LogstashUI/SNMP/static/js/snmp_profile_modal_helpers.js b/src/logstashui/SNMP/static/js/snmp_profile_modal_helpers.js
similarity index 100%
rename from LogstashUI/SNMP/static/js/snmp_profile_modal_helpers.js
rename to src/logstashui/SNMP/static/js/snmp_profile_modal_helpers.js
diff --git a/LogstashUI/SNMP/templates/Credentials.html b/src/logstashui/SNMP/templates/Credentials.html
similarity index 98%
rename from LogstashUI/SNMP/templates/Credentials.html
rename to src/logstashui/SNMP/templates/Credentials.html
index fe110787..873d02e3 100644
--- a/LogstashUI/SNMP/templates/Credentials.html
+++ b/src/logstashui/SNMP/templates/Credentials.html
@@ -36,7 +36,7 @@ Get Started with SNMP Credentials