@@ -325,6 +325,134 @@ def test_vpn_client_post_delete_on_device_deactivation(self):
325325 # Certificate should be revoked (auto_cert=True)
326326 self .assertTrue (Cert .objects .get (pk = cert_pk ).revoked )
327327
328+ def test_vpnclient_delete_partial_failure_on_deactivation (self ):
329+ """Regression test for #1221: if one VpnClient deletion fails
330+ during device deactivation, remaining VpnClients should still
331+ be deleted with their side effects (cert revocation) intact."""
332+ org = self ._get_org ()
333+ vpn1 = self ._create_vpn (name = "vpn1" )
334+ vpn2 = self ._create_vpn (name = "vpn2" )
335+ t1 = self ._create_template (
336+ name = "vpn-test-1" , type = "vpn" , vpn = vpn1 , auto_cert = True
337+ )
338+ t2 = self ._create_template (
339+ name = "vpn-test-2" , type = "vpn" , vpn = vpn2 , auto_cert = True
340+ )
341+ d = self ._create_device (organization = org )
342+ c = self ._create_config (device = d )
343+ c .templates .add (t1 , t2 )
344+ vc1 , vc2 = list (c .vpnclient_set .order_by ("pk" ))
345+ vc1_pk = vc1 .pk
346+ vc2_pk = vc2 .pk
347+ cert2_pk = vc2 .cert .pk
348+
349+ original_delete = VpnClient .delete
350+ call_count = 0
351+
352+ def failing_delete (self ):
353+ nonlocal call_count
354+ call_count += 1
355+ if call_count == 1 :
356+ raise Exception ("simulated failure" )
357+ return original_delete (self )
358+
359+ with mock .patch .object (VpnClient , "delete" , failing_delete ):
360+ with self .assertLogs (
361+ "openwisp_controller.config.base.config" , level = "ERROR"
362+ ) as log_cm :
363+ d .deactivate ()
364+ # First VpnClient delete failed, so it should still exist
365+ self .assertTrue (VpnClient .objects .filter (pk = vc1_pk ).exists ())
366+ # Second VpnClient should be deleted
367+ self .assertFalse (VpnClient .objects .filter (pk = vc2_pk ).exists ())
368+ # Second cert should be revoked
369+ self .assertTrue (Cert .objects .get (pk = cert2_pk ).revoked )
370+ # Error log should contain the VpnClient PK
371+ self .assertTrue (
372+ any (str (vc1_pk ) in msg for msg in log_cm .output ),
373+ f"Expected VpnClient PK { vc1_pk } in log output" ,
374+ )
375+
376+ def test_vpnclient_delete_partial_failure_on_template_removal (self ):
377+ """Regression test for #1221: if one VpnClient deletion fails
378+ during template removal, remaining VpnClients should still
379+ be deleted with their side effects (cert revocation) intact."""
380+ org = self ._get_org ()
381+ vpn1 = self ._create_vpn (name = "vpn1" )
382+ vpn2 = self ._create_vpn (name = "vpn2" )
383+ t1 = self ._create_template (
384+ name = "vpn-test-1" , type = "vpn" , vpn = vpn1 , auto_cert = True
385+ )
386+ t2 = self ._create_template (
387+ name = "vpn-test-2" , type = "vpn" , vpn = vpn2 , auto_cert = True
388+ )
389+ c = self ._create_config (organization = org )
390+ c .templates .add (t1 , t2 )
391+ vc1 , vc2 = list (c .vpnclient_set .order_by ("pk" ))
392+ vc1_pk = vc1 .pk
393+ vc2_pk = vc2 .pk
394+ cert2_pk = vc2 .cert .pk
395+
396+ original_delete = VpnClient .delete
397+ call_count = 0
398+
399+ def failing_delete (self ):
400+ nonlocal call_count
401+ call_count += 1
402+ if call_count == 1 :
403+ raise Exception ("simulated failure" )
404+ return original_delete (self )
405+
406+ with mock .patch .object (VpnClient , "delete" , failing_delete ):
407+ with self .assertLogs (
408+ "openwisp_controller.config.base.config" , level = "ERROR"
409+ ) as log_cm :
410+ c .templates .remove (t1 , t2 )
411+ # First VpnClient delete failed, so it should still exist
412+ self .assertTrue (VpnClient .objects .filter (pk = vc1_pk ).exists ())
413+ # Second VpnClient should be deleted
414+ self .assertFalse (VpnClient .objects .filter (pk = vc2_pk ).exists ())
415+ # Second cert should be revoked
416+ self .assertTrue (Cert .objects .get (pk = cert2_pk ).revoked )
417+ # Error log should contain the VpnClient PK
418+ self .assertTrue (
419+ any (str (vc1_pk ) in msg for msg in log_cm .output ),
420+ f"Expected VpnClient PK { vc1_pk } in log output" ,
421+ )
422+
423+ def test_vpnclient_delete_all_fail_no_crash (self ):
424+ """Regression test for #1221: if all VpnClient deletions fail,
425+ the method should not raise and should log each failure."""
426+ org = self ._get_org ()
427+ vpn1 = self ._create_vpn (name = "vpn1" )
428+ vpn2 = self ._create_vpn (name = "vpn2" )
429+ t1 = self ._create_template (
430+ name = "vpn-test-1" , type = "vpn" , vpn = vpn1 , auto_cert = True
431+ )
432+ t2 = self ._create_template (
433+ name = "vpn-test-2" , type = "vpn" , vpn = vpn2 , auto_cert = True
434+ )
435+ d = self ._create_device (organization = org )
436+ c = self ._create_config (device = d )
437+ c .templates .add (t1 , t2 )
438+ vc1_pk , vc2_pk = list (
439+ c .vpnclient_set .order_by ("pk" ).values_list ("pk" , flat = True )
440+ )
441+
442+ with mock .patch .object (
443+ VpnClient , "delete" , side_effect = Exception ("simulated failure" )
444+ ):
445+ with self .assertLogs (
446+ "openwisp_controller.config.base.config" , level = "ERROR"
447+ ) as log_cm :
448+ d .deactivate ()
449+ # Both VpnClients should still exist (all deletes failed)
450+ self .assertTrue (VpnClient .objects .filter (pk = vc1_pk ).exists ())
451+ self .assertTrue (VpnClient .objects .filter (pk = vc2_pk ).exists ())
452+ # Should have logged 2 errors
453+ error_msgs = [msg for msg in log_cm .output if "Failed to delete" in msg ]
454+ self .assertEqual (len (error_msgs ), 2 )
455+
328456 def test_vpn_client_get_common_name (self ):
329457 vpn = self ._create_vpn ()
330458 d = self ._create_device ()
0 commit comments