@@ -1595,6 +1595,67 @@ def test_device_detail_api_change_config(self):
15951595 self .assertEqual (response .status_code , 200 )
15961596 self .assertEqual (device .config .templates .count (), 0 )
15971597
1598+ def test_vpnclient_delete_partial_failure_on_api_template_change (self ):
1599+ """Regression test for #1221: if one VpnClient deletion fails
1600+ during API template update, remaining VpnClients should still
1601+ be deleted and the API should not crash."""
1602+ org = self ._get_org ()
1603+ vpn1 = self ._create_vpn (name = "vpn1" , organization = org )
1604+ vpn2 = self ._create_vpn (name = "vpn2" , organization = org )
1605+ t1 = self ._create_template (
1606+ name = "vpn-test-1" , type = "vpn" , vpn = vpn1 , organization = org , auto_cert = True
1607+ )
1608+ t2 = self ._create_template (
1609+ name = "vpn-test-2" , type = "vpn" , vpn = vpn2 , organization = org , auto_cert = True
1610+ )
1611+ generic_template = self ._create_template (organization = org )
1612+ device = self ._create_device (organization = org )
1613+ config = self ._create_config (device = device )
1614+ path = reverse ("config_api:device_detail" , args = [device .pk ])
1615+ # Add both VPN templates
1616+ data = {
1617+ "name" : device .name ,
1618+ "organization" : str (org .id ),
1619+ "mac_address" : device .mac_address ,
1620+ "config" : {
1621+ "backend" : "netjsonconfig.OpenWrt" ,
1622+ "templates" : [str (t1 .pk ), str (t2 .pk ), str (generic_template .pk )],
1623+ },
1624+ }
1625+ response = self .client .put (path , data , content_type = "application/json" )
1626+ self .assertEqual (response .status_code , 200 )
1627+ self .assertEqual (config .vpnclient_set .count (), 2 )
1628+ vc1 , vc2 = list (config .vpnclient_set .order_by ("pk" ))
1629+ vc1_pk = vc1 .pk
1630+ vc2_pk = vc2 .pk
1631+
1632+ original_delete = VpnClient .delete
1633+ call_count = 0
1634+
1635+ def failing_delete (self ):
1636+ nonlocal call_count
1637+ call_count += 1
1638+ if call_count == 1 :
1639+ raise Exception ("simulated failure" )
1640+ return original_delete (self )
1641+
1642+ # Remove both VPN templates, keep only generic
1643+ data ["config" ]["templates" ] = [str (generic_template .pk )]
1644+ with patch .object (VpnClient , "delete" , failing_delete ):
1645+ with self .assertLogs (
1646+ "openwisp_controller.config.api.serializers" , level = "ERROR"
1647+ ) as log_cm :
1648+ response = self .client .put (path , data , content_type = "application/json" )
1649+ self .assertEqual (response .status_code , 200 )
1650+ # First VpnClient delete failed, so it should still exist
1651+ self .assertTrue (VpnClient .objects .filter (pk = vc1_pk ).exists ())
1652+ # Second VpnClient should be deleted
1653+ self .assertFalse (VpnClient .objects .filter (pk = vc2_pk ).exists ())
1654+ self .assertTrue (
1655+ any (str (vc1_pk ) in msg for msg in log_cm .output ),
1656+ f"Expected VpnClient PK { vc1_pk } in log output" ,
1657+ )
1658+
15981659 def test_multiple_vpn_client_templates_same_vpn (self ):
15991660 """
16001661 Assigning multiple templates of type 'vpn' referencing the same VPN
0 commit comments