@@ -188,6 +188,107 @@ def test_stop_container_clears_name_on_failure(self):
188188 assert cs_mod ._container_name is None
189189
190190
191+ # ---------------------------------------------------------------------------
192+ # Persistent container tests
193+ # ---------------------------------------------------------------------------
194+
195+ class TestPersistentContainer :
196+ def setup_method (self ):
197+ _reset_container ()
198+
199+ def test_persistent_name_uses_hash (self ):
200+ with patch .object (cs_mod , "CONTAINER_IMAGE" , "myregistry.io/org/image:v1.2.3" ):
201+ with patch .object (cs_mod , "CONTAINER_PERSIST_KEY" , "" ):
202+ name = cs_mod ._persistent_name ()
203+ assert name .startswith ("seclab-persist-" )
204+ assert len (name ) == len ("seclab-persist-" ) + 12
205+
206+ def test_persistent_name_varies_with_key (self ):
207+ with patch .object (cs_mod , "CONTAINER_IMAGE" , "test-image:latest" ):
208+ with patch .object (cs_mod , "CONTAINER_PERSIST_KEY" , "" ):
209+ name_a = cs_mod ._persistent_name ()
210+ with patch .object (cs_mod , "CONTAINER_PERSIST_KEY" , "run-42" ):
211+ name_b = cs_mod ._persistent_name ()
212+ assert name_a != name_b
213+
214+ def test_persistent_name_differs_for_different_images (self ):
215+ with patch .object (cs_mod , "CONTAINER_PERSIST_KEY" , "" ):
216+ with patch .object (cs_mod , "CONTAINER_IMAGE" , "image-a:latest" ):
217+ name_a = cs_mod ._persistent_name ()
218+ with patch .object (cs_mod , "CONTAINER_IMAGE" , "image-b:latest" ):
219+ name_b = cs_mod ._persistent_name ()
220+ assert name_a != name_b
221+
222+ def test_start_reuses_running_persistent_container (self ):
223+ inspect_proc = _make_proc (
224+ returncode = 0 ,
225+ stdout = '[{"State":{"Running":true}}]' ,
226+ )
227+ with (
228+ patch .object (cs_mod , "CONTAINER_IMAGE" , "test-image:latest" ),
229+ patch .object (cs_mod , "CONTAINER_WORKSPACE" , "" ),
230+ patch .object (cs_mod , "CONTAINER_PERSIST" , True ),
231+ patch .object (cs_mod , "CONTAINER_PERSIST_KEY" , "" ),
232+ patch ("subprocess.run" , return_value = inspect_proc ) as mock_run ,
233+ ):
234+ name = cs_mod ._start_container ()
235+ assert name .startswith ("seclab-persist-" )
236+ # Only docker inspect should be called, NOT docker run
237+ assert mock_run .call_count == 1
238+ cmd = mock_run .call_args [0 ][0 ]
239+ assert cmd == ["docker" , "inspect" , "--format" , "json" , name ]
240+
241+ def test_start_persistent_no_rm_flag (self ):
242+ inspect_proc = _make_proc (
243+ returncode = 1 ,
244+ stdout = "" ,
245+ )
246+ rm_proc = _make_proc (returncode = 0 )
247+ run_proc = _make_proc (returncode = 0 )
248+ with (
249+ patch .object (cs_mod , "CONTAINER_IMAGE" , "test-image:latest" ),
250+ patch .object (cs_mod , "CONTAINER_WORKSPACE" , "" ),
251+ patch .object (cs_mod , "CONTAINER_PERSIST" , True ),
252+ patch .object (cs_mod , "CONTAINER_PERSIST_KEY" , "" ),
253+ patch ("subprocess.run" , side_effect = [inspect_proc , rm_proc , run_proc ]) as mock_run ,
254+ ):
255+ name = cs_mod ._start_container ()
256+ assert name .startswith ("seclab-persist-" )
257+ # The docker run call is the third one
258+ run_cmd = mock_run .call_args_list [2 ][0 ][0 ]
259+ assert "--rm" not in run_cmd
260+
261+ def test_stop_skips_persistent_container (self ):
262+ cs_mod ._container_name = "seclab-persist-abc123"
263+ with (
264+ patch .object (cs_mod , "CONTAINER_PERSIST" , True ),
265+ patch ("subprocess.run" ) as mock_run ,
266+ ):
267+ cs_mod ._stop_container ()
268+ mock_run .assert_not_called ()
269+ assert cs_mod ._container_name is None
270+
271+ def test_remove_container_logs_failure (self ):
272+ with patch ("subprocess.run" , return_value = _make_proc (returncode = 1 , stderr = "conflict" )):
273+ with patch .object (cs_mod .logging , "debug" ) as mock_debug :
274+ cs_mod ._remove_container ("test-name" )
275+ mock_debug .assert_called_once ()
276+
277+ def test_remove_container_logs_timeout (self ):
278+ with patch ("subprocess.run" , side_effect = subprocess .TimeoutExpired (cmd = "docker" , timeout = 30 )):
279+ with patch .object (cs_mod .logging , "exception" ) as mock_err :
280+ cs_mod ._remove_container ("test-name" )
281+ mock_err .assert_called_once ()
282+
283+ def test_is_running_returns_false_on_timeout (self ):
284+ with patch ("subprocess.run" , side_effect = subprocess .TimeoutExpired (cmd = "docker" , timeout = 30 )):
285+ assert cs_mod ._is_running ("test-name" ) is False
286+
287+ def test_is_running_returns_false_on_bad_json (self ):
288+ with patch ("subprocess.run" , return_value = _make_proc (returncode = 0 , stdout = "not json" )):
289+ assert cs_mod ._is_running ("test-name" ) is False
290+
291+
191292# ---------------------------------------------------------------------------
192293# Toolbox YAML validation
193294# ---------------------------------------------------------------------------
0 commit comments