1212# See the License for the specific language governing permissions and
1313# limitations under the License.
1414"""End to end tests for sketch archiving."""
15- import os
1615import time
16+ import uuid
17+ import opensearchpy
18+
19+ from timesketch_api_client import search
1720
1821from . import interface
1922from . import manager
@@ -26,15 +29,13 @@ class ArchiveTest(interface.BaseEndToEndTest):
2629
2730 def test_archive_sketch_with_failed_timeline (self ):
2831 """Test archiving a sketch with a failed timeline."""
29- sketch = self .api .create_sketch (name = "test-archive-failed" )
32+ sketch_name = f"test-archive-failed_{ uuid .uuid4 ().hex } "
33+ sketch = self .api .create_sketch (name = sketch_name )
3034 self .sketch = sketch
3135 self .assertions .assertIsNotNone (sketch )
3236
3337 # This file is known to cause an import failure.
34- file_path = os .path .join (
35- os .path .dirname (__file__ ), "test_data" , "invalid_jsonl.jsonl"
36- )
37- timeline = self .import_timeline (file_path , sketch = sketch )
38+ timeline = self .import_timeline ("invalid_jsonl.jsonl" , sketch = sketch )
3839
3940 # Wait for the timeline to fail.
4041 for _ in range (20 ):
@@ -51,5 +52,233 @@ def test_archive_sketch_with_failed_timeline(self):
5152 index = self .api .get_searchindex (index_name )
5253 self .assertions .assertEqual (index .status , "closed" )
5354
55+ def test_unarchive_sketch_with_failed_timeline (self ):
56+ """Test unarchiving a sketch with a failed timeline."""
57+ sketch_name = f"test-unarchive-failed_{ uuid .uuid4 ().hex } "
58+ sketch = self .api .create_sketch (name = sketch_name )
59+ self .sketch = sketch
60+
61+ # This file is known to cause an import failure.
62+ timeline = self .import_timeline ("invalid_jsonl.jsonl" , sketch = sketch )
63+
64+ # Wait for the timeline to fail.
65+ for _ in range (20 ):
66+ time .sleep (1 )
67+ if timeline .status == "fail" :
68+ break
69+
70+ self .assertions .assertEqual (timeline .status , "fail" )
71+ sketch .archive ()
72+ self .assertions .assertEqual (sketch .status , "archived" )
73+ # Unarchive
74+ sketch .unarchive ()
75+ self .assertions .assertEqual (sketch .status , "ready" )
76+ self .assertions .assertEqual (timeline .status , "fail" )
77+
78+ def test_unarchive_sketch_with_missing_index (self ):
79+ """Test unarchiving a sketch where the OpenSearch index is missing."""
80+ sketch_name = f"test-unarchive-missing-index_{ uuid .uuid4 ().hex } "
81+ sketch = self .api .create_sketch (name = sketch_name )
82+ self .sketch = sketch
83+
84+ # Import a valid timeline first
85+ timeline = self .import_timeline ("sigma_events.jsonl" , sketch = sketch )
86+ index_name = timeline .index_name
87+
88+ sketch .archive ()
89+ self .assertions .assertEqual (sketch .status , "archived" )
90+
91+ # Manually delete the index from OpenSearch
92+ es = opensearchpy .OpenSearch (
93+ [
94+ {
95+ "host" : interface .OPENSEARCH_HOST ,
96+ "port" : interface .OPENSEARCH_PORT ,
97+ }
98+ ],
99+ http_compress = True ,
100+ )
101+ # Note: Index should be closed now, so delete might need it to be open?
102+ # Or we can delete a closed index.
103+ es .indices .delete (index = index_name )
104+
105+ # Verify it's gone
106+ self .assertions .assertFalse (es .indices .exists (index = index_name ))
107+
108+ # Try to unarchive
109+ sketch .unarchive ()
110+
111+ # Should succeed (become ready) despite missing index
112+ self .assertions .assertEqual (sketch .status , "ready" )
113+
114+ timeline = sketch .list_timelines ()[0 ]
115+
116+ # So ALL timelines in the sketch are set to 'ready', regardless of
117+ # index status! This is because timelines and searchindices are
118+ # separate. SearchIndex status is updated only if in
119+ # successfully_opened_indexes.
120+
121+ self .assertions .assertEqual (timeline .status , "fail" )
122+
123+ # Check SearchIndex status
124+ index = self .api .get_searchindex (index_name )
125+ # Should be 'archived' because it wasn't successfully opened.
126+ self .assertions .assertEqual (index .status , "archived" )
127+
128+ def test_unarchive_mixed_indices (self ):
129+ """Test unarchiving a sketch with one good and one missing index.
130+
131+ Logic:
132+ 1. Create a sketch with two timelines: A (Good) and B (Bad).
133+ 2. B's index is manually deleted to simulate failure.
134+ 3. Archive the sketch.
135+ 4. Unarchive the sketch.
136+ 5. Verify that Timeline A remains READY and Timeline B is FAIL.
137+ 6. Delete the failed Timeline B.
138+ 7. Verify the sketch can be archived and unarchived again successfully.
139+ 8. Verify Timeline A is still READY and searchable.
140+ """
141+ sketch_name = f"test-unarchive-mixed_{ uuid .uuid4 ().hex } "
142+ sketch = self .api .create_sketch (name = sketch_name )
143+
144+ # Import Timeline A (Good)
145+ tl_a = self .import_timeline ("sigma_events.jsonl" , sketch = sketch )
146+
147+ # Import Timeline B (Bad - will be deleted)
148+ tl_b = self .import_timeline ("evtx_part.csv" , sketch = sketch )
149+ idx_b_name = tl_b .index_name
150+
151+ sketch .archive ()
152+ self .assertions .assertEqual (sketch .status , "archived" )
153+
154+ # Delete Index B
155+ es = opensearchpy .OpenSearch (
156+ [
157+ {
158+ "host" : interface .OPENSEARCH_HOST ,
159+ "port" : interface .OPENSEARCH_PORT ,
160+ }
161+ ],
162+ http_compress = True ,
163+ )
164+ es .indices .delete (index = idx_b_name )
165+
166+ # Unarchive
167+ sketch .unarchive ()
168+
169+ # Verify Sketch Ready
170+ self .assertions .assertEqual (sketch .status , "ready" )
171+
172+ # Refetch timelines
173+ timelines = {t .id : t for t in self .api .get_sketch (sketch .id ).list_timelines ()}
174+
175+ # Timeline A should be ready
176+ self .assertions .assertEqual (timelines [tl_a .id ].status , "ready" )
177+
178+ # Timeline B should be fail
179+ self .assertions .assertEqual (timelines [tl_b .id ].status , "fail" )
180+
181+ # Verify we can search Timeline A
182+ search_client = search .Search (sketch )
183+ search_client .query_filter .update ({"indices" : [tl_a .id ]})
184+ results = search_client .table
185+ self .assertions .assertEqual (len (results ), 4 )
186+
187+ # Delete the failed timeline B
188+ timelines [tl_b .id ].delete ()
189+
190+ # Archive again
191+ sketch .archive ()
192+ self .assertions .assertEqual (sketch .status , "archived" )
193+
194+ # Unarchive again
195+ sketch .unarchive ()
196+ self .assertions .assertEqual (sketch .status , "ready" )
197+
198+ # Verify Timeline A is still ready and working
199+ final_timelines = self .api .get_sketch (sketch .id ).list_timelines ()
200+ self .assertions .assertEqual (final_timelines [0 ].status , "ready" )
201+
202+ # Verify search still works
203+ results = search_client .table
204+ self .assertions .assertEqual (len (results ), 4 )
205+
206+ def test_unarchive_shared_index_missing (self ):
207+ """Test unarchiving when a shared index is missing."""
208+ sketch_name = f"test-unarchive-shared-missing_{ uuid .uuid4 ().hex } "
209+ sketch = self .api .create_sketch (name = sketch_name )
210+
211+ shared_index_name = uuid .uuid4 ().hex
212+
213+ # Import Timeline A
214+ tl_a = self .import_timeline (
215+ "sigma_events.jsonl" , sketch = sketch , index_name = shared_index_name
216+ )
217+
218+ # Import Timeline B into same index
219+ tl_b = self .import_timeline (
220+ "evtx_part.csv" , sketch = sketch , index_name = shared_index_name
221+ )
222+
223+ # Verify they share the index
224+ self .assertions .assertEqual (tl_a .index_name , tl_b .index_name )
225+
226+ sketch .archive ()
227+ self .assertions .assertEqual (sketch .status , "archived" )
228+
229+ # Delete the shared index
230+ es = opensearchpy .OpenSearch (
231+ [
232+ {
233+ "host" : interface .OPENSEARCH_HOST ,
234+ "port" : interface .OPENSEARCH_PORT ,
235+ }
236+ ],
237+ http_compress = True ,
238+ )
239+ es .indices .delete (index = shared_index_name )
240+
241+ # Unarchive
242+ sketch .unarchive ()
243+
244+ # Both timelines should be 'fail' because their shared index is gone
245+ timelines = {t .id : t for t in self .api .get_sketch (sketch .id ).list_timelines ()}
246+ self .assertions .assertEqual (timelines [tl_a .id ].status , "fail" )
247+ self .assertions .assertEqual (timelines [tl_b .id ].status , "fail" )
248+
249+ def test_archive_cycle_with_deletion (self ):
250+ """Test archive -> unarchive -> delete timeline -> archive -> unarchive."""
251+ sketch_name = f"test-archive-cycle_{ uuid .uuid4 ().hex } "
252+ sketch = self .api .create_sketch (name = sketch_name )
253+
254+ # 1. Setup: Import Timeline
255+ timeline = self .import_timeline ("sigma_events.jsonl" , sketch = sketch )
256+
257+ # 2. Cycle 1: Archive -> Unarchive
258+ sketch .archive ()
259+ self .assertions .assertEqual (sketch .status , "archived" )
260+
261+ sketch .unarchive ()
262+ self .assertions .assertEqual (sketch .status , "ready" )
263+
264+ # Verify timeline is ready
265+ timeline = sketch .list_timelines ()[0 ]
266+ self .assertions .assertEqual (timeline .status , "ready" )
267+
268+ # 3. Delete Timeline
269+ timeline .delete ()
270+
271+ # Verify it's gone from list (or deleted status if list includes deleted?)
272+ # sketch.list_timelines() usually excludes deleted by default in client?
273+ # Let's check length.
274+ self .assertions .assertEqual (len (sketch .list_timelines ()), 0 )
275+
276+ # 4. Cycle 2: Archive -> Unarchive (Empty sketch)
277+ sketch .archive ()
278+ self .assertions .assertEqual (sketch .status , "archived" )
279+
280+ sketch .unarchive ()
281+ self .assertions .assertEqual (sketch .status , "ready" )
282+
54283
55284manager .EndToEndTestManager .register_test (ArchiveTest )
0 commit comments