diff --git a/checkbox-support/checkbox_support/helpers/retry.py b/checkbox-support/checkbox_support/helpers/retry.py index 7c667f0bd4..0108f3ff48 100644 --- a/checkbox-support/checkbox_support/helpers/retry.py +++ b/checkbox-support/checkbox_support/helpers/retry.py @@ -47,7 +47,9 @@ def run_with_retry(f, max_attempts, delay, *args, **kwargs): "delay should be at least 1 ({} was used)".format(delay) ) for attempt in range(1, max_attempts + 1): - attempt_string = "Attempt {}/{}".format(attempt, max_attempts) + attempt_string = "Attempt {}/{} (function '{}')".format( + attempt, max_attempts, f.__name__ + ) print() print("=" * len(attempt_string)) print(attempt_string) @@ -97,7 +99,7 @@ def fake_run_with_retry(f, max_attempts, delay, *args, **kwargs): return f(*args, **kwargs) -mock_timeout = functools.partial( +mock_retry = functools.partial( patch, "checkbox_support.helpers.retry.run_with_retry", new=fake_run_with_retry, diff --git a/providers/base/bin/wifi_nmcli_test.py b/providers/base/bin/wifi_nmcli_test.py index d4b2981fc9..185cf0cfe7 100755 --- a/providers/base/bin/wifi_nmcli_test.py +++ b/providers/base/bin/wifi_nmcli_test.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 -# Copyright 2017-2019 Canonical Ltd. +# Copyright 2017-2024 Canonical Ltd. # All rights reserved. # # Written by: # Jonathan Cave # Taihsiang Ho +# Isaac Yang +# Pierre Equoy # # wireless connection tests using nmcli @@ -15,11 +17,11 @@ import os import subprocess as sp import sys -import time import shlex from packaging import version as version_parser +from checkbox_support.helpers.retry import retry from gateway_ping_test import ping @@ -89,13 +91,10 @@ def turn_down_nm_connections(): for name, value in connections.items(): uuid = value["uuid"] print("Turn down connection", name) - try: - cmd = "nmcli c down {}".format(uuid) - print_cmd(cmd) - sp.call(shlex.split(cmd)) - print("{} {} is down now".format(name, uuid)) - except sp.CalledProcessError as e: - print("Can't down {}: {}".format(uuid, str(e))) + cmd = "nmcli c down {}".format(uuid) + print_cmd(cmd) + sp.check_call(shlex.split(cmd)) + print("{} {} is down now".format(name, uuid)) print() @@ -105,26 +104,18 @@ def delete_test_ap_ssid_connection(): if "TEST_CON" not in connections: print("No TEST_CON connection found, nothing to delete") return - try: - cmd = "nmcli c delete TEST_CON" - print_cmd(cmd) - sp.call(shlex.split(cmd)) - print("TEST_CON is deleted") - except Exception as e: - print("Can't delete TEST_CON : {}".format(str(e))) + cmd = "nmcli c delete TEST_CON" + print_cmd(cmd) + sp.check_call(shlex.split(cmd)) + print("TEST_CON is deleted") +@retry(max_attempts=5, delay=60) def device_rescan(): print_head("Calling a rescan") cmd = "nmcli d wifi rescan" print_cmd(cmd) - retcode = sp.call(shlex.split(cmd)) - if retcode != 0: - # Most often the rescan request fails because NM has itself started - # a scan in recent past, we should let these operations complete before - # attempting a connection - print("Scan request failed, allow other operations to complete (15s)") - time.sleep(15) + sp.check_call(shlex.split(cmd)) print() @@ -183,48 +174,59 @@ def perform_ping_test(interface): if target: count = 5 result = ping(target, interface, count, 10) - if result["received"] == count: - return True + if result["received"] != count: + raise ValueError( + "{} packets expected but only {} received".format( + count, result["received"] + ) + ) - return False +@retry(max_attempts=5, delay=1) +def wait_for_connected(interface, essid): + cmd = ( + "nmcli -m tabular -t -f GENERAL.STATE,GENERAL.CONNECTION " + "d show {}".format(interface) + ) + print_cmd(cmd) + output = sp.check_output(shlex.split(cmd), universal_newlines=True) + print(output) + state, ssid = output.strip().splitlines() -def wait_for_connected(interface, essid, max_wait=5): - connected = False - attempts = 0 - while not connected and attempts < max_wait: - cmd = ( - "nmcli -m tabular -t -f GENERAL.STATE,GENERAL.CONNECTION " - "d show {}".format(interface) - ) - print_cmd(cmd) - output = sp.check_output(shlex.split(cmd)) - state, ssid = output.decode(sys.stdout.encoding).strip().splitlines() + if state.startswith("100") and ssid == essid: + print("Reached connected state with ESSID: {}".format(essid)) + elif ssid != essid: + error_msg = ( + "ERROR: did not reach connected state with ESSID: {}\n" + "ESSID mismatch:\n Excepted:{}\n Actually:{}" + ).format(essid, ssid, essid) + raise SystemExit(error_msg) + elif not state.startswith("100"): + error_msg = "State is not connected: {}".format(state) + raise SystemExit(error_msg) + print() - if state.startswith("100") and ssid == essid: - connected = True - break - time.sleep(1) - attempts += 1 +def connection(cmd, device): + print_head("Connection attempt") + print_cmd(cmd) + sp.check_call(shlex.split(cmd)) - if connected: - print("Reached connected state with ESSID: {}".format(essid)) - else: - print( - "ERROR: did not reach connected state with ESSID: {}".format(essid) - ) - if ssid != essid: - print( - "ESSID mismatch:\n Excepted:{}\n Actually:{}".format( - ssid, essid - ) - ) - if not state.startswith("100"): - print("State is not connected: {}".format(state)) + # Make sure the connection is brought up + turn_up_connection("TEST_CON") - print() - return connected + print_head("Ensure interface is connected") + wait_for_connected(device, "TEST_CON") + + print_head("Display address") + print_address_info(device) + + print_head("Display route table") + print_route_info() + + print_head("Perform a ping test") + perform_ping_test(device) + print("Connection test passed\n") def open_connection(args): @@ -233,7 +235,6 @@ def open_connection(args): # ipv6.method ignore : I believe that NM can report the device as Connected # if an IPv6 address is setup. This should ensure in # this test we are using IPv4 - print_head("Connection attempt") cmd = ( "nmcli c add con-name TEST_CON " "ifname {} " @@ -244,31 +245,7 @@ def open_connection(args): "ipv4.dhcp-timeout 30 " "ipv6.method ignore".format(args.device, args.essid) ) - print_cmd(cmd) - sp.call(shlex.split(cmd)) - - # Make sure the connection is brought up - turn_up_connection("TEST_CON") - - print_head("Ensure interface is connected") - reached_connected = wait_for_connected(args.device, "TEST_CON") - - rc = 1 - if reached_connected: - print_head("Display address") - print_address_info(args.device) - - print_head("Display route table") - print_route_info() - - print_head("Perform a ping test") - test_result = perform_ping_test(args.device) - if test_result: - rc = 0 - print("Connection test passed\n") - else: - print("Connection test failed\n") - return rc + connection(cmd, args.device) def secured_connection(args): @@ -277,7 +254,6 @@ def secured_connection(args): # ipv6.method ignore : I believe that NM can report the device as Connected # if an IPv6 address is setup. This should ensure in # this test we are using IPv4 - print_head("Connection attempt") cmd = ( "nmcli c add con-name TEST_CON " "ifname {} " @@ -292,31 +268,7 @@ def secured_connection(args): args.device, args.essid, args.exchange, args.psk ) ) - print_cmd(cmd) - sp.call(shlex.split(cmd)) - - # Make sure the connection is brought up - turn_up_connection("TEST_CON") - - print_head("Ensure interface is connected") - reached_connected = wait_for_connected(args.device, "TEST_CON") - - rc = 1 - if reached_connected: - print_head("Display address") - print_address_info(args.device) - - print_head("Display route table") - print_route_info() - - print_head("Perform a ping test") - test_result = perform_ping_test(args.device) - if test_result: - rc = 0 - print("Connection test passed\n") - else: - print("Connection test failed\n") - return rc + connection(cmd, args.device) def hotspot(args): @@ -414,6 +366,7 @@ def parser_args(): return args +@retry(max_attempts=5, delay=60) def main(): args = parser_args() start_time = datetime.datetime.now() @@ -424,32 +377,33 @@ def main(): if args.test_type == "scan": if not aps_dict: - print("Failed to find any APs") - return 1 + raise SystemExit("Failed to find any access point.") else: print("Found {} access points".format(len(aps_dict))) - return 0 + return if not aps_dict: - print("Targed access points: {} not found".format(args.essid)) - return 1 + raise SystemExit( + "Targed access point: {} not found".format(args.essid) + ) if args.func: delete_test_ap_ssid_connection() activated_uuid = get_nm_activate_connection() turn_down_nm_connections() try: - result = args.func(args) + args.func(args) + except Exception: + # The test is not required to run as root, but root access is + # required for journal access so only attempt to print when e.g. + # running under Remote + if os.geteuid() == 0: + print_journal_entries(start_time) + raise finally: turn_up_connection(activated_uuid) delete_test_ap_ssid_connection() - # The test is not required to run as root, but root access is required for - # journal access so only attempt to print when e.g. running under Remote - if result != 0 and os.geteuid() == 0: - print_journal_entries(start_time) - return result - if __name__ == "__main__": sys.exit(main()) diff --git a/providers/base/tests/test_wifi_nmcli_test.py b/providers/base/tests/test_wifi_nmcli_test.py index 6d7d9e3dba..b84730023e 100644 --- a/providers/base/tests/test_wifi_nmcli_test.py +++ b/providers/base/tests/test_wifi_nmcli_test.py @@ -16,8 +16,12 @@ # along with this program. If not, see . +import subprocess import unittest from unittest.mock import patch, call, MagicMock + +from checkbox_support.helpers.retry import mock_retry + from wifi_nmcli_test import ( legacy_nmcli, _get_nm_wireless_connections, @@ -29,8 +33,12 @@ list_aps, show_aps, wait_for_connected, + connection, open_connection, secured_connection, + print_address_info, + print_route_info, + perform_ping_test, hotspot, parser_args, main, @@ -136,34 +144,37 @@ def test_no_connections_to_turn_down( self.assertEqual(get_connections_mock.call_count, 1) sp_call_mock.assert_not_called() - @patch("wifi_nmcli_test.sp.call") + @patch("wifi_nmcli_test.sp.check_call") @patch( "wifi_nmcli_test._get_nm_wireless_connections", return_value={"Wireless1": {"uuid": "uuid1", "state": "activated"}}, ) def test_turn_down_single_connection( - self, get_connections_mock, sp_call_mock + self, get_connections_mock, sp_check_call_mock ): turn_down_nm_connections() self.assertEqual(get_connections_mock.call_count, 1) - sp_call_mock.assert_called_once_with("nmcli c down uuid1".split()) + sp_check_call_mock.assert_called_once_with( + "nmcli c down uuid1".split() + ) - @patch( - "wifi_nmcli_test.sp.call", side_effect=Exception("Error turning down") - ) + @patch("wifi_nmcli_test.sp.check_call") @patch( "wifi_nmcli_test._get_nm_wireless_connections", return_value={"Wireless1": {"uuid": "uuid1", "state": "activated"}}, ) def test_turn_down_single_connection_with_exception( - self, get_connections_mock, sp_call_mock + self, get_connections_mock, sp_check_call_mock ): - with self.assertRaises(Exception): + sp_check_call_mock.side_effect = subprocess.CalledProcessError("", 1) + with self.assertRaises(subprocess.CalledProcessError): turn_down_nm_connections() self.assertEqual(get_connections_mock.call_count, 1) - sp_call_mock.assert_called_once_with("nmcli c down uuid1".split()) + sp_check_call_mock.assert_called_once_with( + "nmcli c down uuid1".split() + ) - @patch("wifi_nmcli_test.sp.call") + @patch("wifi_nmcli_test.sp.check_call") @patch( "wifi_nmcli_test._get_nm_wireless_connections", return_value={ @@ -172,7 +183,7 @@ def test_turn_down_single_connection_with_exception( }, ) def test_turn_down_multiple_connections( - self, get_connections_mock, sp_call_mock + self, get_connections_mock, sp_check_call_mock ): turn_down_nm_connections() self.assertEqual(get_connections_mock.call_count, 1) @@ -180,11 +191,11 @@ def test_turn_down_multiple_connections( call("nmcli c down uuid1".split()), call("nmcli c down uuid2".split()), ] - sp_call_mock.assert_has_calls(calls, any_order=True) + sp_check_call_mock.assert_has_calls(calls, any_order=True) class TestDeleteTestApSsidConnection(unittest.TestCase): - @patch("wifi_nmcli_test.sp.call", return_value=0) + @patch("wifi_nmcli_test.sp.check_call") @patch( "wifi_nmcli_test._get_nm_wireless_connections", return_value={ @@ -193,27 +204,11 @@ class TestDeleteTestApSsidConnection(unittest.TestCase): ) @patch("wifi_nmcli_test.print") def test_delete_existing_test_con( - self, print_mock, get_nm_wireless_connections_mock, sp_call_mock + self, print_mock, get_nm_wireless_connections_mock, sp_check_call_mock ): delete_test_ap_ssid_connection() print_mock.assert_called_with("TEST_CON is deleted") - @patch("wifi_nmcli_test.sp.call", side_effect=Exception("Deletion failed")) - @patch( - "wifi_nmcli_test._get_nm_wireless_connections", - return_value={ - "TEST_CON": {"uuid": "uuid-test", "state": "deactivated"} - }, - ) - @patch("wifi_nmcli_test.print") - def test_delete_test_con_exception( - self, print_mock, get_nm_wireless_connections_mock, sp_call_mock - ): - delete_test_ap_ssid_connection() - print_mock.assert_called_with( - "Can't delete TEST_CON : Deletion failed" - ) - @patch("wifi_nmcli_test._get_nm_wireless_connections", return_value={}) @patch("wifi_nmcli_test.print") def test_no_test_con_to_delete( @@ -276,158 +271,175 @@ def test_show_aps_multiple_aps(self, mock_print): mock_print.assert_has_calls(expected_calls, any_order=True) +@mock_retry() class TestWaitForConnected(unittest.TestCase): @patch("wifi_nmcli_test.print_cmd", new=MagicMock()) - @patch("wifi_nmcli_test.time.sleep", MagicMock(return_value=None)) @patch( "wifi_nmcli_test.sp.check_output", MagicMock( side_effect=[ - b"30:disconnected\nTestESSID", - b"100:connected\nTestESSID", + "100:connected\nTestESSID\n", ] ), ) def test_wait_for_connected_success(self): interface = "wlan0" essid = "TestESSID" - self.assertTrue(wait_for_connected(interface, essid)) + wait_for_connected(interface, essid) @patch( "wifi_nmcli_test.sp.check_output", - MagicMock(return_value=b"30:disconnected\nTestESSID"), + MagicMock(return_value="30:disconnected\nTestESSID\n"), ) @patch("wifi_nmcli_test.print_cmd", new=MagicMock()) - @patch("wifi_nmcli_test.time.sleep", MagicMock(return_value=None)) def test_wait_for_connected_failure_due_to_timeout(self): interface = "wlan0" essid = "TestESSID" - self.assertFalse(wait_for_connected(interface, essid, max_wait=3)) + with self.assertRaises(SystemExit): + wait_for_connected(interface, essid) @patch( "wifi_nmcli_test.sp.check_output", - MagicMock(return_value=b"100:connected\nWrongESSID"), + MagicMock(return_value="100:connected\nWrongESSID\n"), ) @patch("wifi_nmcli_test.print_cmd", new=MagicMock()) - @patch("wifi_nmcli_test.time.sleep", MagicMock(return_value=None)) def test_wait_for_connected_failure_due_to_essid_mismatch(self): interface = "wlan0" essid = "TestESSID" - self.assertFalse(wait_for_connected(interface, essid)) + with self.assertRaises(SystemExit): + wait_for_connected(interface, essid) -class TestOpenConnection(unittest.TestCase): - @patch("wifi_nmcli_test.sp.call", new=MagicMock()) - @patch("wifi_nmcli_test.print_address_info", new=MagicMock()) - @patch("wifi_nmcli_test.print_route_info", new=MagicMock()) - @patch("wifi_nmcli_test.turn_up_connection", new=MagicMock()) - @patch("wifi_nmcli_test.print_head", new=MagicMock()) - @patch("wifi_nmcli_test.print_cmd", new=MagicMock()) - @patch("wifi_nmcli_test.perform_ping_test", return_value=True) - @patch("wifi_nmcli_test.wait_for_connected", return_value=True) - def test_open_connection_success( - self, perform_ping_test_mock, wait_for_connected_mock - ): - args = type("", (), {})() - args.device = "wlan0" - args.essid = "TestESSID" - rc = open_connection(args) - self.assertEqual(rc, 0) - - @patch("wifi_nmcli_test.sp.call", new=MagicMock()) - @patch("wifi_nmcli_test.print_address_info", new=MagicMock()) - @patch("wifi_nmcli_test.print_route_info", new=MagicMock()) - @patch("wifi_nmcli_test.turn_up_connection", new=MagicMock()) - @patch("wifi_nmcli_test.print_head", new=MagicMock()) - @patch("wifi_nmcli_test.print_cmd", new=MagicMock()) - @patch("wifi_nmcli_test.perform_ping_test", MagicMock(return_value=False)) - @patch("wifi_nmcli_test.wait_for_connected", MagicMock(return_value=True)) - def test_open_connection_failed_ping(self): - args = type("", (), {})() - args.device = "wlan0" - args.essid = "TestESSID" - rc = open_connection(args) - self.assertEqual(rc, 1) - - @patch("wifi_nmcli_test.sp.call", new=MagicMock()) - @patch("wifi_nmcli_test.print_head", new=MagicMock()) - @patch("wifi_nmcli_test.print_cmd", new=MagicMock()) - @patch("wifi_nmcli_test.turn_up_connection", new=MagicMock()) - @patch("wifi_nmcli_test.wait_for_connected", MagicMock(return_value=False)) - def test_open_connection_failed_to_connect(self): - args = type("", (), {})() - args.device = "wlan0" - args.essid = "TestESSID" - rc = open_connection(args) - self.assertEqual(rc, 1) - - -class TestSecuredConnection(unittest.TestCase): - @patch("wifi_nmcli_test.sp.call", new=MagicMock()) +class TestConnection(unittest.TestCase): + @patch("wifi_nmcli_test.sp.check_call", new=MagicMock()) @patch("wifi_nmcli_test.print_route_info", new=MagicMock()) @patch("wifi_nmcli_test.print_address_info", new=MagicMock()) @patch("wifi_nmcli_test.turn_up_connection", new=MagicMock()) @patch("wifi_nmcli_test.sp.check_output", new=MagicMock()) @patch("wifi_nmcli_test.wait_for_connected", return_value=True) @patch("wifi_nmcli_test.perform_ping_test", return_value=True) - def test_secured_connection_success( + def test_connection_success( self, perform_ping_test_mock, wait_for_connected_mock, ): - args = type("", (), {})() - args.device = "wlan0" - args.essid = "TestSSID" - args.exchange = "wpa-psk" - args.psk = "password123" - rc = secured_connection(args) - self.assertEqual(rc, 0) + cmd = "test" + device = "wlan0" + connection(cmd, device) wait_for_connected_mock.assert_called_with("wlan0", "TEST_CON") perform_ping_test_mock.assert_called_with("wlan0") - @patch("wifi_nmcli_test.sp.call", new=MagicMock()) + @patch("wifi_nmcli_test.sp.check_call", new=MagicMock()) @patch("wifi_nmcli_test.print_route_info", new=MagicMock()) @patch("wifi_nmcli_test.print_address_info", new=MagicMock()) @patch("wifi_nmcli_test.turn_up_connection", new=MagicMock()) @patch("wifi_nmcli_test.sp.check_output", new=MagicMock()) - @patch("wifi_nmcli_test.wait_for_connected", return_value=False) + @patch("wifi_nmcli_test.wait_for_connected") @patch("wifi_nmcli_test.perform_ping_test", return_value=False) - def test_secured_connection_fail_to_connect( + def test_connection_fail_to_connect( self, perform_ping_test_mock, wait_for_connected_mock, ): - args = type("", (), {})() - args.device = "wlan0" - args.essid = "TestSSID" - args.exchange = "wpa-psk" - args.psk = "password123" - rc = secured_connection(args) - self.assertEqual(rc, 1) + wait_for_connected_mock.side_effect = SystemExit() + cmd = "test" + device = "wlan0" + with self.assertRaises(SystemExit): + connection(cmd, device) wait_for_connected_mock.assert_called_with("wlan0", "TEST_CON") perform_ping_test_mock.assert_not_called() - @patch("wifi_nmcli_test.sp.call", new=MagicMock()) + @patch("wifi_nmcli_test.sp.run") @patch("wifi_nmcli_test.print_route_info", new=MagicMock()) @patch("wifi_nmcli_test.print_address_info", new=MagicMock()) @patch("wifi_nmcli_test.turn_up_connection", new=MagicMock()) @patch("wifi_nmcli_test.sp.check_output", new=MagicMock()) @patch("wifi_nmcli_test.wait_for_connected", return_value=False) @patch("wifi_nmcli_test.perform_ping_test", return_value=True) - def test_secured_connection_command_failure( + def test_connection_command_failure( self, perform_ping_test_mock, wait_for_connected_mock, + sp_run_mock, ): - args = type("", (), {})() + sp_run_mock.side_effect = subprocess.CalledProcessError("", 1) + cmd = "test" + device = "wlan0" + with self.assertRaises(subprocess.CalledProcessError): + connection(cmd, device) + wait_for_connected_mock.assert_not_called() + perform_ping_test_mock.assert_not_called() + + +class TestOpenConnection(unittest.TestCase): + @patch("wifi_nmcli_test.connection") + def test_open_connection(self, mock_connection): + """ + Check that security-related parameters are absent in the command + sent to connection(). + """ + args = MagicMock() args.device = "wlan0" args.essid = "TestSSID" args.exchange = "wpa-psk" args.psk = "password123" - rc = secured_connection(args) - self.assertEqual(rc, 1) - wait_for_connected_mock.assert_called_with("wlan0", "TEST_CON") - perform_ping_test_mock.assert_not_called() + open_connection(args) + self.assertNotIn("wifi-sec", mock_connection.call_args[0][0]) + + +class TestSecuredConnection(unittest.TestCase): + @patch("wifi_nmcli_test.connection") + def test_secured_connection(self, mock_connection): + """ + Check that security-related parameters are present in the command + sent to connection(). + """ + args = MagicMock() + args.device = "wlan0" + args.essid = "TestSSID" + args.exchange = "wpa-psk" + args.psk = "password123" + secured_connection(args) + self.assertIn("wifi-sec", mock_connection.call_args[0][0]) + + +class TestDeviceRescan(unittest.TestCase): + @patch("wifi_nmcli_test.sp.check_call") + def test_device_rescan_success(self, mock_sp_check_call): + device_rescan() + + +class TestPrintAddressInfo(unittest.TestCase): + @patch("wifi_nmcli_test.sp.call") + def test_print_address_info_success(self, mock_sp_call): + print_address_info("wlan0") + + +class TestPrintRouteInfo(unittest.TestCase): + @patch("wifi_nmcli_test.sp.call") + def test_print_route_info_success(self, mock_sp_call): + print_route_info() + + +@patch("wifi_nmcli_test.ping") +@patch("wifi_nmcli_test.sp.check_output") +class TestPerformPingTest(unittest.TestCase): + def test_perform_ping_test_success(self, mock_check_output, mock_ping): + mock_ping.return_value = { + "transmitted": 5, + "received": 5, + "pct_loss": 0, + } + perform_ping_test("wlan0") + + def test_perform_ping_test_failure(self, mock_check_output, mock_ping): + mock_ping.return_value = { + "transmitted": 5, + "received": 0, + "pct_loss": 0, + } + with self.assertRaises(ValueError): + perform_ping_test("wlan0") class TestParserArgs(unittest.TestCase): @@ -480,6 +492,7 @@ def test_parser_args_ap(self): self.assertEqual(args.band, "5GHz") +@mock_retry() class TestMainFunction(unittest.TestCase): @patch("wifi_nmcli_test.delete_test_ap_ssid_connection", new=MagicMock()) @@ -496,7 +509,8 @@ def test_main_scan_no_aps_found( list_aps_mock, get_nm_activate_connection_mock, ): - main() + with self.assertRaises(SystemExit): + main() @patch("wifi_nmcli_test.delete_test_ap_ssid_connection", new=MagicMock()) @patch("wifi_nmcli_test.turn_down_nm_connections", new=MagicMock()) @@ -540,7 +554,8 @@ def test_main_open_no_aps_found( list_aps_mock, get_nm_activate_connection_mock, ): - main() + with self.assertRaises(SystemExit): + main() @patch("wifi_nmcli_test.delete_test_ap_ssid_connection", new=MagicMock()) @patch(