spl2-testing-framework 1.6.0__tar.gz → 1.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/PKG-INFO +49 -3
  2. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/README.md +48 -2
  3. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/pyproject.toml +1 -1
  4. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/conftest.py +8 -2
  5. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/spl2test_runner.py +15 -3
  6. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/splunk_integration/splunk_handler.py +68 -19
  7. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/test_discovery.py +144 -16
  8. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/utils.py +9 -4
  9. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/LICENSE +0 -0
  10. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/__init__.py +0 -0
  11. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/launch_remotely_spl2.py +0 -0
  12. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/logger_manager.py +0 -0
  13. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/pytest.ini.sample +0 -0
  14. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/single_spl2_file_runner.py +0 -0
  15. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/spl2_test_config.json +0 -0
  16. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/spl2_utils/assertions.spl2 +0 -0
  17. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/spl2_utils/logs_to_metrics.spl2 +0 -0
  18. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/test_runner.py +0 -0
  19. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/__init__.py +0 -0
  20. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/create_diff_sheet.py +0 -0
  21. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/jobs/__init__.py +0 -0
  22. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/jobs/cli_job.py +0 -0
  23. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/jobs/cloud_job.py +0 -0
  24. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/jobs/job.py +0 -0
  25. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/jobs/splunk_job.py +0 -0
  26. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/jobs/test_cloud_job.py +0 -0
  27. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/performance.py +0 -0
  28. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/results.py +0 -0
  29. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/search_clients/__init__.py +0 -0
  30. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/search_clients/cli_search_client.py +0 -0
  31. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/search_clients/cloud_search_client.py +0 -0
  32. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/search_clients/search_client.py +0 -0
  33. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/search_clients/splunk_search_client.py +0 -0
  34. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/splunk_integration/__init__.py +0 -0
  35. {spl2_testing_framework-1.6.0 → spl2_testing_framework-1.7.1}/spl2_testing_framework/tools/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spl2-testing-framework
3
- Version: 1.6.0
3
+ Version: 1.7.1
4
4
  Summary: SPL2 Testing Framework
5
5
  Home-page: https://github.com/splunk/spl2-testing-framework
6
6
  Keywords: SPL2 testing framework
@@ -131,6 +131,8 @@ It is possible to pass more options to the command, which works also with pytest
131
131
 
132
132
  Additionally, the following options are supported:
133
133
 
134
+ * `--test_dir` - directory where test files (`.test.json`, `.test.spl2`, `module.json`) are located. Defaults to the current working directory. Discovery is recursive.
135
+ * `--code_dir` - directory where SPL2 code modules (`.spl2`) are located. Defaults to the current working directory. Falls back to `--test_dir` if the module is not found. See [Separate code and test directories](#separate-code-and-test-directories) for details.
134
136
  * `--ignore_empty_strings` - to ignore empty strings in the results
135
137
  * `--ignore_additional_fields_in_actual` - to ignore fields present in actual results but not in expected results (useful when actual results contain extra fields that should not affect comparison)
136
138
  * `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
@@ -152,10 +154,53 @@ Sample configuration which may be used:
152
154
  - Parameters:
153
155
  `--type [cli | splunk | cloud] --test_dir /tests/resources -o log_cli=true --log-cli-level=INFO --verbose`
154
156
  - If test dir is not specified, current working directory will be used
157
+ - Use `--code_dir` if SPL2 code modules are in a separate directory from tests
155
158
  - If necessary another pytest options can be added
156
159
 
157
160
  Note: It's necessary to set "pytest" as default test runner in PyCharm settings
158
161
 
162
+ ### Separate code and test directories
163
+
164
+ By default, the framework expects SPL2 code modules (`.spl2` files) to live alongside their test files. The `--code_dir` option allows you to keep code and tests in separate directory trees.
165
+
166
+ #### How it works
167
+
168
+ | What is searched | Primary directory | Fallback directory |
169
+ |------------------|-------------------|--------------------|
170
+ | **Code modules** (`.spl2`) | `--code_dir` | `--test_dir` |
171
+ | **Test files** (`.test.json`, `.test.spl2`) | `--test_dir` | `--code_dir` |
172
+
173
+ Both searches are recursive. If the primary directory has no results, the framework falls back to the other directory and logs the action.
174
+
175
+ When multiple files with the same name are found within a directory, a warning is logged and the lexicographically first match is used.
176
+
177
+ #### Example project layout
178
+
179
+ ```
180
+ my-project/
181
+ ├── src/ # --code_dir
182
+ │ ├── network_traffic.spl2
183
+ │ └── dns_lookup.spl2
184
+ └── tests/ # --test_dir
185
+ ├── network_traffic.test.json
186
+ ├── dns_lookup.test.json
187
+ └── dns_lookup.test.spl2
188
+ ```
189
+
190
+ Run with:
191
+
192
+ ```bash
193
+ spl2_tests_run cli --test_dir tests --code_dir src
194
+ ```
195
+
196
+ #### Backward compatibility
197
+
198
+ When `--code_dir` is not specified (or both flags point to the same directory), the framework behaves exactly as before — code modules are found next to their test files via the same recursive search.
199
+
200
+ #### Applies to all test types
201
+
202
+ The `--code_dir` option works for box tests, unit tests, and single SPL2 file execution.
203
+
159
204
  ## Executing a spl2 file
160
205
 
161
206
  This framework also supports executing a single spl2 file and prints the results in command line as well as a log file.
@@ -179,6 +224,7 @@ It is possible to pass more options to the command, which works also with pytest
179
224
 
180
225
  * `--test_dir` - Path in which the template file and module.json are available. If not provided, it will look for the
181
226
  current directory for the template file and module.json file
227
+ * `--code_dir` - Path where SPL2 code modules are located, if different from `--test_dir`. Falls back to `--test_dir` when not set. See [Separate code and test directories](#separate-code-and-test-directories).
182
228
  * `--template_file` - The spl2 template file to execute
183
229
  * `--sample_file` - A file containing all the samples required for the template file. If not provided, it will look for
184
230
  the samples in module.json file of the corresponding template file
@@ -214,8 +260,8 @@ When set (`--check_splunk_results=<option>`), box tests (after running the SPL2
214
260
 
215
261
  | Value | Behavior |
216
262
  |-------|----------|
217
- | **CIM** | For each output event, push to Splunk, query by host, then validate that the returned event has the expected CIM fields (from `expected_cim_fields.cim_fields` in the box test). Uses `validate_compatibility()`. Skips the test if the box test has no `cim_fields`. |
218
- | **TA** | For each output event, push to Splunk, query by host, then assert that the returned event equals the expected output event for that index. |
263
+ | **CIM** | For each output event, ingest via HEC, load back from Splunk, then check CIM fields from `expected_cim_fields.cim_fields` with `validate_compatibility(actual, expected)`. Skips if the box test has no `cim_fields`. |
264
+ | **TA** | For each output event, ingest via HEC, load back from Splunk, then assert equality with the expected row for that index. |
219
265
 
220
266
  For **TA** only, you can name top-level fields to drop on **both** the queried event and each `expected_destination_result` row by adding **`ignore_fields_in_splunk_check`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
221
267
 
@@ -98,6 +98,8 @@ It is possible to pass more options to the command, which works also with pytest
98
98
 
99
99
  Additionally, the following options are supported:
100
100
 
101
+ * `--test_dir` - directory where test files (`.test.json`, `.test.spl2`, `module.json`) are located. Defaults to the current working directory. Discovery is recursive.
102
+ * `--code_dir` - directory where SPL2 code modules (`.spl2`) are located. Defaults to the current working directory. Falls back to `--test_dir` if the module is not found. See [Separate code and test directories](#separate-code-and-test-directories) for details.
101
103
  * `--ignore_empty_strings` - to ignore empty strings in the results
102
104
  * `--ignore_additional_fields_in_actual` - to ignore fields present in actual results but not in expected results (useful when actual results contain extra fields that should not affect comparison)
103
105
  * `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
@@ -119,10 +121,53 @@ Sample configuration which may be used:
119
121
  - Parameters:
120
122
  `--type [cli | splunk | cloud] --test_dir /tests/resources -o log_cli=true --log-cli-level=INFO --verbose`
121
123
  - If test dir is not specified, current working directory will be used
124
+ - Use `--code_dir` if SPL2 code modules are in a separate directory from tests
122
125
  - If necessary another pytest options can be added
123
126
 
124
127
  Note: It's necessary to set "pytest" as default test runner in PyCharm settings
125
128
 
129
+ ### Separate code and test directories
130
+
131
+ By default, the framework expects SPL2 code modules (`.spl2` files) to live alongside their test files. The `--code_dir` option allows you to keep code and tests in separate directory trees.
132
+
133
+ #### How it works
134
+
135
+ | What is searched | Primary directory | Fallback directory |
136
+ |------------------|-------------------|--------------------|
137
+ | **Code modules** (`.spl2`) | `--code_dir` | `--test_dir` |
138
+ | **Test files** (`.test.json`, `.test.spl2`) | `--test_dir` | `--code_dir` |
139
+
140
+ Both searches are recursive. If the primary directory has no results, the framework falls back to the other directory and logs the action.
141
+
142
+ When multiple files with the same name are found within a directory, a warning is logged and the lexicographically first match is used.
143
+
144
+ #### Example project layout
145
+
146
+ ```
147
+ my-project/
148
+ ├── src/ # --code_dir
149
+ │ ├── network_traffic.spl2
150
+ │ └── dns_lookup.spl2
151
+ └── tests/ # --test_dir
152
+ ├── network_traffic.test.json
153
+ ├── dns_lookup.test.json
154
+ └── dns_lookup.test.spl2
155
+ ```
156
+
157
+ Run with:
158
+
159
+ ```bash
160
+ spl2_tests_run cli --test_dir tests --code_dir src
161
+ ```
162
+
163
+ #### Backward compatibility
164
+
165
+ When `--code_dir` is not specified (or both flags point to the same directory), the framework behaves exactly as before — code modules are found next to their test files via the same recursive search.
166
+
167
+ #### Applies to all test types
168
+
169
+ The `--code_dir` option works for box tests, unit tests, and single SPL2 file execution.
170
+
126
171
  ## Executing a spl2 file
127
172
 
128
173
  This framework also supports executing a single spl2 file and prints the results in command line as well as a log file.
@@ -146,6 +191,7 @@ It is possible to pass more options to the command, which works also with pytest
146
191
 
147
192
  * `--test_dir` - Path in which the template file and module.json are available. If not provided, it will look for the
148
193
  current directory for the template file and module.json file
194
+ * `--code_dir` - Path where SPL2 code modules are located, if different from `--test_dir`. Falls back to `--test_dir` when not set. See [Separate code and test directories](#separate-code-and-test-directories).
149
195
  * `--template_file` - The spl2 template file to execute
150
196
  * `--sample_file` - A file containing all the samples required for the template file. If not provided, it will look for
151
197
  the samples in module.json file of the corresponding template file
@@ -181,8 +227,8 @@ When set (`--check_splunk_results=<option>`), box tests (after running the SPL2
181
227
 
182
228
  | Value | Behavior |
183
229
  |-------|----------|
184
- | **CIM** | For each output event, push to Splunk, query by host, then validate that the returned event has the expected CIM fields (from `expected_cim_fields.cim_fields` in the box test). Uses `validate_compatibility()`. Skips the test if the box test has no `cim_fields`. |
185
- | **TA** | For each output event, push to Splunk, query by host, then assert that the returned event equals the expected output event for that index. |
230
+ | **CIM** | For each output event, ingest via HEC, load back from Splunk, then check CIM fields from `expected_cim_fields.cim_fields` with `validate_compatibility(actual, expected)`. Skips if the box test has no `cim_fields`. |
231
+ | **TA** | For each output event, ingest via HEC, load back from Splunk, then assert equality with the expected row for that index. |
186
232
 
187
233
  For **TA** only, you can name top-level fields to drop on **both** the queried event and each `expected_destination_result` row by adding **`ignore_fields_in_splunk_check`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
188
234
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  [tool.poetry]
17
17
  name = "spl2-testing-framework"
18
- version = "1.6.0"
18
+ version = "1.7.1"
19
19
  description = "SPL2 Testing Framework"
20
20
  authors = ["mgazda-splunk <mgazda@splunk.com>"]
21
21
  readme = "README.md"
@@ -55,6 +55,7 @@ CHECK_SPLUNK_RESULTS = "check_splunk_results"
55
55
  PERFORMANCE_CHECK = "performance_check"
56
56
  TEST_TYPE = "type"
57
57
  TEST_DIR = "test_dir"
58
+ CODE_DIR = "code_dir"
58
59
  LIMIT_TESTS = "limit_tests"
59
60
  CLI_BENCH = "cli_bench"
60
61
 
@@ -70,6 +71,7 @@ _LOGGER = logging.getLogger(__name__)
70
71
  def pytest_addoption(parser):
71
72
  parser.addoption(f"--{TEST_TYPE}", action="store", default="cli")
72
73
  parser.addoption(f"--{TEST_DIR}", action="store", default=".")
74
+ parser.addoption(f"--{CODE_DIR}", action="store", default=".")
73
75
  parser.addoption(
74
76
  f"--{PERFORMANCE_CHECK}",
75
77
  action="store",
@@ -93,13 +95,16 @@ def pytest_generate_tests(metafunc):
93
95
  """This function is used for dynamic generation of tests based on the test files in the directory specified by the
94
96
  --test_dir option"""
95
97
  test_dir = metafunc.config.getoption(TEST_DIR)
98
+ code_dir = metafunc.config.getoption(CODE_DIR)
96
99
  performance_check = metafunc.config.getoption(PERFORMANCE_CHECK)
97
100
  limit_tests = metafunc.config.getoption(LIMIT_TESTS)
98
101
  _set_results_options(metafunc)
99
102
 
100
103
  if BOX_TEST in metafunc.fixturenames:
101
104
  _LOGGER.info("Collecting Box Tests")
102
- module_tests = BoxTestDiscovery(test_dir, performance_check, limit_tests)
105
+ module_tests = BoxTestDiscovery(
106
+ test_dir, performance_check, limit_tests, code_dir=code_dir
107
+ )
103
108
  module_tests.discover_tests()
104
109
  metafunc.parametrize(
105
110
  "%s" % BOX_TEST, module_tests.tests, ids=module_tests.tests.get_ids()
@@ -119,6 +124,7 @@ def pytest_generate_tests(metafunc):
119
124
  sample_delimiter,
120
125
  performance_check=performance_check,
121
126
  limit_tests=limit_tests,
127
+ code_dir=code_dir,
122
128
  )
123
129
  try:
124
130
  spl2_pipelines.discover_tests()
@@ -132,7 +138,7 @@ def pytest_generate_tests(metafunc):
132
138
 
133
139
  if UNIT_TEST in metafunc.fixturenames:
134
140
  _LOGGER.info("Collecting Unit Tests")
135
- unit_tests = UTDiscovery(test_dir, limit_tests)
141
+ unit_tests = UTDiscovery(test_dir, limit_tests, code_dir=code_dir)
136
142
  unit_tests.discover_tests()
137
143
  metafunc.parametrize(
138
144
  UNIT_TEST, unit_tests.tests, ids=unit_tests.tests.get_ids()
@@ -23,6 +23,9 @@ from spl2_testing_framework.tools.performance import PerformanceCheck
23
23
  from spl2_testing_framework.tools.search_clients.cli_search_client import (
24
24
  CLISearchClient,
25
25
  )
26
+ from spl2_testing_framework.tools.splunk_integration.splunk_handler import (
27
+ SplunkEventNotFoundError,
28
+ )
26
29
  from spl2_testing_framework.tools.test_types import UnitTest, BoxTest, SingleSPL2
27
30
  from spl2_testing_framework.tools.utils import (
28
31
  _make_functions_visible_for_testing,
@@ -227,6 +230,15 @@ class SPL2TestRunner:
227
230
  return row
228
231
  return {k: v for k, v in row.items() if k not in ignore_keys}
229
232
 
233
+ def _splunk_process_event(self, box_test: BoxTest, event):
234
+ try:
235
+ return self.splunk_handler.process_event(event)
236
+ except SplunkEventNotFoundError as e:
237
+ raise SplunkEventNotFoundError(
238
+ e.event_uuid,
239
+ context=f"box_test={box_test.name!r}",
240
+ ) from e
241
+
230
242
  def _check_splunk_results(self, box_test, job):
231
243
  output_result = job.result.get("destination", [])
232
244
  if self.check_splunk_results == "CIM":
@@ -234,13 +246,13 @@ class SPL2TestRunner:
234
246
  pytest.skip("CIM output not available for this test")
235
247
 
236
248
  for event in output_result:
237
- processed_event = self.splunk_handler.process_event(event)
238
- validate_compatibility(box_test.cim_fields, processed_event)
249
+ processed_event = self._splunk_process_event(box_test, event)
250
+ validate_compatibility(processed_event, box_test.cim_fields)
239
251
  elif self.check_splunk_results == "TA":
240
252
  ignore_spec = box_test.ignore_fields_in_splunk_check
241
253
  ignore_keys = frozenset(ignore_spec.keys())
242
254
  for event, expected in zip(output_result, box_test.output):
243
- processed_event = self.splunk_handler.process_event(event)
255
+ processed_event = self._splunk_process_event(box_test, event)
244
256
  actual = self._strip_ignored_fields_for_splunk_compare(
245
257
  processed_event, ignore_keys
246
258
  )
@@ -14,6 +14,7 @@
14
14
 
15
15
  import logging
16
16
  import uuid
17
+ from typing import Optional
17
18
 
18
19
  import requests
19
20
  import splunklib.client as client
@@ -26,6 +27,21 @@ from requests.auth import HTTPBasicAuth
26
27
  _LOGGER = logging.getLogger(__name__)
27
28
 
28
29
 
30
+ SPL2_TF_EVENT_UUID_FIELD = "spl2_testing_framework_event_uuid"
31
+ SPL2_TF_DEFAULT_HEC_HOST = "spl2-testing-framework"
32
+
33
+
34
+ class SplunkEventNotFoundError(Exception):
35
+ """Raised when HEC ingest succeeded but search returned no matching event."""
36
+
37
+ def __init__(self, event_uuid: str, *, context: Optional[str] = None):
38
+ self.event_uuid = event_uuid
39
+ msg = f"No event found after HEC submit (event_uuid={event_uuid!r})"
40
+ if context:
41
+ msg = f"{context}: {msg}"
42
+ super().__init__(msg)
43
+
44
+
29
45
  class SplunkHandler:
30
46
  def __init__(self, config, auth):
31
47
  self._config = config
@@ -36,18 +52,45 @@ class SplunkHandler:
36
52
  self._create_index()
37
53
 
38
54
  def process_event(self, event):
39
- host = f"test_{uuid.uuid4()}"
40
-
41
- self.push_to_hec(event, host)
42
- res = self.get_events_by_host_and_index(host)
55
+ event_uuid = str(uuid.uuid4())
56
+ metadata_host = event.get("host")
57
+ if metadata_host is not None and str(metadata_host).strip() != "":
58
+ hec_host = str(metadata_host).strip()
59
+ else:
60
+ hec_host = SPL2_TF_DEFAULT_HEC_HOST
61
+
62
+ self.push_to_hec(event, hec_host, event_uuid)
63
+ res = self.get_events_by_event_uuid(event_uuid)
43
64
  if not res:
44
- raise Exception("No event found.")
65
+ raise SplunkEventNotFoundError(event_uuid)
45
66
 
46
67
  if len(res) > 1:
47
68
  _LOGGER.error("Found more than one event, the result may not be correct")
48
- return res[0]
49
-
50
- def push_to_hec(self, data, host):
69
+ out = dict(res[0])
70
+ out.pop(SPL2_TF_EVENT_UUID_FIELD, None)
71
+ return out
72
+
73
+ @staticmethod
74
+ def _hec_event_url(raw_url: str) -> str:
75
+ if "/services/collector/raw" in raw_url:
76
+ return raw_url.replace(
77
+ "/services/collector/raw", "/services/collector/event"
78
+ )
79
+ return raw_url
80
+
81
+ @staticmethod
82
+ def _raw_as_event_string(raw):
83
+ """Normalize pipeline `_raw` for HEC JSON `event` (must be a string).
84
+
85
+ ``str`` is passed through ``str()``; ``bytes`` are decoded as UTF-8 with
86
+ replacement on invalid sequences (avoids :class:`UnicodeDecodeError` for
87
+ odd binary payloads; prefer valid UTF-8 log text in normal use).
88
+ """
89
+ if isinstance(raw, bytes):
90
+ return raw.decode("utf-8", errors="replace")
91
+ return str(raw)
92
+
93
+ def push_to_hec(self, data, host, event_uuid):
51
94
  sourcetype = data.get("sourcetype", "UNKNOWN")
52
95
 
53
96
  if sourcetype == "UNKNOWN":
@@ -55,29 +98,31 @@ class SplunkHandler:
55
98
 
56
99
  source = data.get("source", None)
57
100
 
58
- params = {
101
+ payload = {
102
+ "event": SplunkHandler._raw_as_event_string(data["_raw"]),
59
103
  "sourcetype": sourcetype,
60
104
  "index": self.splunk_config["index"],
61
105
  "host": host,
106
+ "fields": {SPL2_TF_EVENT_UUID_FIELD: event_uuid},
62
107
  }
63
-
64
108
  if source is not None:
65
- params["source"] = source
66
-
67
- event_data = data["_raw"].encode("utf-8")
109
+ payload["source"] = source
68
110
 
69
111
  response = requests.post(
70
- url=self.splunk_config["url"],
71
- data=event_data,
72
- params=params,
112
+ url=self._hec_event_url(self.splunk_config["url"]),
113
+ json=payload,
73
114
  auth=self._auth,
74
115
  verify=False,
75
116
  )
76
117
 
77
118
  response.raise_for_status()
78
119
 
79
- def get_events_by_host_and_index(self, host):
80
- SPL_TEMPLATE = "| search index={index} host={host} | fields *"
120
+ @staticmethod
121
+ def _splunk_search_term(value: str) -> str:
122
+ return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
123
+
124
+ def get_events_by_event_uuid(self, event_uuid):
125
+ SPL_TEMPLATE = "| search index={index} {uuid_field}={uuid} | fields *"
81
126
  # Connect to Splunk
82
127
  service = client.connect(
83
128
  host=self.splunk_config["ip"],
@@ -86,7 +131,11 @@ class SplunkHandler:
86
131
  password=self.splunk_config["password"],
87
132
  app="search",
88
133
  )
89
- SPL = SPL_TEMPLATE.format(index=self.splunk_config["index"], host=host)
134
+ SPL = SPL_TEMPLATE.format(
135
+ index=self.splunk_config["index"],
136
+ uuid_field=SPL2_TF_EVENT_UUID_FIELD,
137
+ uuid=self._splunk_search_term(event_uuid),
138
+ )
90
139
 
91
140
  results_all = []
92
141
  retry = 0
@@ -19,6 +19,8 @@ import logging
19
19
  import os
20
20
  import pathlib
21
21
  import re
22
+ from collections import Counter
23
+ from typing import Optional
22
24
 
23
25
  from spl2_testing_framework.tools.results import Metrics, Results
24
26
  from spl2_testing_framework.tools.test_types import (
@@ -34,33 +36,151 @@ _LOGGER = logging.getLogger(__name__)
34
36
  class SamplesNotFoundException(Exception):
35
37
  """An Exception to be raised if samples are not found"""
36
38
 
37
- pass
39
+
40
+ class CodeModuleNotFoundError(Exception):
41
+ """Raised when a code module cannot be found in any search directory"""
38
42
 
39
43
 
40
44
  class TestDiscovery(abc.ABC):
41
45
  """Base class for test discovery classes"""
42
46
 
43
- def __init__(self, path: str, performance_check: str, limit_tests: int = None):
47
+ def __init__(
48
+ self,
49
+ path: str,
50
+ performance_check: str,
51
+ limit_tests: Optional[int] = None,
52
+ code_dir: Optional[str] = None,
53
+ ):
44
54
  self.path = pathlib.Path(path)
55
+ self.code_dir = pathlib.Path(code_dir) if code_dir else self.path
45
56
  self._performance_check = performance_check
46
57
  self.limit_tests = limit_tests
47
58
  self.tests = TestSuite()
48
59
 
60
+ @property
61
+ def _dirs_differ(self) -> bool:
62
+ try:
63
+ return self._dirs_differ_cached
64
+ except AttributeError:
65
+ self._dirs_differ_cached = self.code_dir.resolve() != self.path.resolve()
66
+ return self._dirs_differ_cached
67
+
68
+ @staticmethod
69
+ def _warn_duplicate_basenames(
70
+ paths: list, directory: pathlib.Path, label: str
71
+ ) -> None:
72
+ names = [p.name for p in paths]
73
+ dupes = [name for name, count in Counter(names).items() if count > 1]
74
+ if dupes:
75
+ _LOGGER.warning(
76
+ "Multiple %s files with the same name found in '%s': %s",
77
+ label,
78
+ directory,
79
+ dupes,
80
+ )
81
+
49
82
  def _read_tests(self, test_name: str) -> None:
50
- tests_paths = self.path.rglob(test_name)
83
+ _LOGGER.info(
84
+ "Searching for test files '%s' in test directory: %s",
85
+ test_name,
86
+ self.path,
87
+ )
88
+ tests_paths = list(self.path.rglob(test_name))
89
+ if tests_paths:
90
+ self._warn_duplicate_basenames(tests_paths, self.path, "test")
91
+ _LOGGER.info(
92
+ "Found %d test file(s) in test directory: %s",
93
+ len(tests_paths),
94
+ [str(p) for p in tests_paths],
95
+ )
96
+ elif self._dirs_differ:
97
+ _LOGGER.info(
98
+ "No test files '%s' found in test directory '%s', "
99
+ "falling back to code directory: %s",
100
+ test_name,
101
+ self.path,
102
+ self.code_dir,
103
+ )
104
+ tests_paths = list(self.code_dir.rglob(test_name))
105
+ if tests_paths:
106
+ self._warn_duplicate_basenames(tests_paths, self.code_dir, "test")
107
+ _LOGGER.info(
108
+ "Found %d test file(s) in code directory: %s",
109
+ len(tests_paths),
110
+ [str(p) for p in tests_paths],
111
+ )
112
+ else:
113
+ _LOGGER.warning(
114
+ "No test files '%s' found in code directory either.", test_name
115
+ )
116
+ else:
117
+ _LOGGER.warning("No test files '%s' found in test directory.", test_name)
118
+
51
119
  self._raw_tests = {
52
120
  x: pathlib.Path(x).read_text(encoding="utf-8") for x in tests_paths
53
121
  }
54
122
 
123
+ def _search_directory(
124
+ self, filename: str, directory: pathlib.Path, label: str
125
+ ) -> Optional[pathlib.Path]:
126
+ """Search a directory recursively for *filename*.
127
+
128
+ Returns the first match (sorted lexicographically) or ``None``.
129
+ Warns when multiple matches exist.
130
+ """
131
+ matches = sorted(directory.rglob(filename))
132
+ if not matches:
133
+ return None
134
+ if len(matches) > 1:
135
+ _LOGGER.warning(
136
+ "Multiple matches for code module '%s' in %s '%s': %s. "
137
+ "Using first match.",
138
+ filename,
139
+ label,
140
+ directory,
141
+ [str(m) for m in matches],
142
+ )
143
+ _LOGGER.info("Resolved code module (%s): %s", label, matches[0])
144
+ return matches[0]
145
+
146
+ def _resolve_code_module(self, filename: str) -> pathlib.Path:
147
+ """Resolve a code module by filename.
148
+
149
+ Searches code_dir first (recursive), falls back to test_dir.
150
+ Warns when multiple files share the same name in a directory.
151
+ """
152
+ _LOGGER.info(
153
+ "Searching for code module '%s' in code directory: %s",
154
+ filename,
155
+ self.code_dir,
156
+ )
157
+ result = self._search_directory(filename, self.code_dir, "code directory")
158
+ if result is not None:
159
+ return result
160
+
161
+ if self._dirs_differ:
162
+ _LOGGER.info(
163
+ "Code module '%s' not found in code directory '%s', "
164
+ "falling back to test directory: %s",
165
+ filename,
166
+ self.code_dir,
167
+ self.path,
168
+ )
169
+ result = self._search_directory(filename, self.path, "test directory")
170
+ if result is not None:
171
+ return result
172
+
173
+ raise CodeModuleNotFoundError(
174
+ f"Code module '{filename}' not found in code directory '{self.code_dir}' "
175
+ f"or test directory '{self.path}'"
176
+ )
177
+
55
178
 
56
179
  class BoxTestDiscovery(TestDiscovery):
57
180
  """Discovers tests for box tests, looking into module.test.json files"""
58
181
 
59
182
  _IGNORE_FIELDS_IN_SPLUNK_CHECK_KEY = "ignore_fields_in_splunk_check"
60
183
 
61
- def __init__(self, path: str, performance_check: str, limit_tests: int = None):
62
- super().__init__(path, performance_check, limit_tests)
63
-
64
184
  @classmethod
65
185
  def _parse_ignore_fields_in_splunk_check(cls, test_block: dict) -> dict:
66
186
  raw = test_block.get(cls._IGNORE_FIELDS_IN_SPLUNK_CHECK_KEY)
@@ -110,7 +230,6 @@ class BoxTestDiscovery(TestDiscovery):
110
230
  }
111
231
 
112
232
  for module_path, content in self._parsed_modules.items():
113
- path = module_path.parent
114
233
  tests_added = 0
115
234
  for entry in content:
116
235
  if self.limit_tests is not None and tests_added >= self.limit_tests:
@@ -121,7 +240,7 @@ class BoxTestDiscovery(TestDiscovery):
121
240
  test_block = entry["test"]
122
241
  name = entry["filename"].replace(".spl2", "")
123
242
  test_input = test_block["source"]
124
- code_module_path = path / entry["filename"]
243
+ code_module_path = self._resolve_code_module(entry["filename"])
125
244
  output = [
126
245
  Results(x)
127
246
  for x in test_block.get("expected_destination_result", [])
@@ -161,9 +280,10 @@ class SingleSPL2Discovery(TestDiscovery):
161
280
  sample_file=None,
162
281
  sample_delimiter="\n",
163
282
  performance_check="no",
164
- limit_tests: int = None,
283
+ limit_tests: Optional[int] = None,
284
+ code_dir: Optional[str] = None,
165
285
  ):
166
- super().__init__(path, performance_check, limit_tests)
286
+ super().__init__(path, performance_check, limit_tests, code_dir=code_dir)
167
287
  self.template_file = template_file
168
288
  self.sample_file = sample_file
169
289
  self.sample_delimiter = sample_delimiter
@@ -180,7 +300,9 @@ class SingleSPL2Discovery(TestDiscovery):
180
300
  _LOGGER.info("Reading samples from provided sample file.")
181
301
  events = self._read_samples_from_file(delimiter=self.sample_delimiter)
182
302
  name = self.template_file.replace(".spl2", "")
183
- code_module_path = next(self.path.rglob(self.template_file), None)
303
+ code_module_path = self._resolve_code_module(
304
+ os.path.basename(self.template_file)
305
+ )
184
306
  else:
185
307
  _LOGGER.info("Reading samples from corresponding module.json file")
186
308
  self._parsed_modules = {
@@ -188,12 +310,11 @@ class SingleSPL2Discovery(TestDiscovery):
188
310
  }
189
311
  events = None
190
312
  for module_path, content in self._parsed_modules.items():
191
- path = module_path.parent
192
313
  break_outer = False
193
314
  for test in content:
194
315
  if test["filename"] == os.path.basename(self.template_file):
195
316
  name = test["filename"].replace(".spl2", "")
196
- code_module_path = path / test["filename"]
317
+ code_module_path = self._resolve_code_module(test["filename"])
197
318
  events = test["context"]["events"]
198
319
  if isinstance(events, str):
199
320
  events = events.split("\r\n")
@@ -246,8 +367,15 @@ class SingleSPL2Discovery(TestDiscovery):
246
367
  class UTDiscovery(TestDiscovery):
247
368
  """Discovers tests for unit tests, looking into .test.spl2 files"""
248
369
 
249
- def __init__(self, path: str, limit_tests: int = None):
250
- super().__init__(path, performance_check="no", limit_tests=limit_tests)
370
+ def __init__(
371
+ self,
372
+ path: str,
373
+ limit_tests: Optional[int] = None,
374
+ code_dir: Optional[str] = None,
375
+ ):
376
+ super().__init__(
377
+ path, performance_check="no", limit_tests=limit_tests, code_dir=code_dir
378
+ )
251
379
 
252
380
  def discover_tests(self) -> None:
253
381
  self._read_tests("*.test.spl2")
@@ -264,7 +392,7 @@ class UTDiscovery(TestDiscovery):
264
392
  file_name = path.name
265
393
  content = path.read_text(encoding="utf-8")
266
394
  code_module_name = file_name.replace(".test.", ".")
267
- code_module_path = path.parent / code_module_name
395
+ code_module_path = self._resolve_code_module(code_module_name)
268
396
 
269
397
  tests_added = 0
270
398
  for test in tests:
@@ -67,13 +67,18 @@ def calculate_reduction_rate(original_event, reduced_event):
67
67
  return reduction_rates
68
68
 
69
69
 
70
- def validate_compatibility(expected, parsed_output):
71
- """Validate if all fields from parsed_output are present in expected and have the same value."""
70
+ def validate_compatibility(actual, expected):
71
+ """Check that every field in ``expected`` exists in ``actual`` with the same value.
72
+
73
+ Order matches the usual test convention ``assert actual == expected`` (observed
74
+ value first, reference second). For CIM checks, ``actual`` is the Splunk-processed
75
+ event and ``expected`` is ``box_test.cim_fields``.
76
+ """
72
77
  extracted_but_wrong = {}
73
78
  not_extracted = {}
74
79
  for field, value in expected.items():
75
- if field in parsed_output:
76
- if parsed_output[field] == value:
80
+ if field in actual:
81
+ if actual[field] == value:
77
82
  _LOGGER.info(f"FIELD {field} in reduced event")
78
83
  continue
79
84
  else: