blob: 19cc8ae892ebb04284b6b5c82bb4d4ab06a791ba [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import asyncio
import logging
import fidl_fuchsia_wlan_common as f_wlan_common
import fidl_fuchsia_wlan_ieee80211 as f_wlan_ieee80211
import fidl_fuchsia_wlan_internal as fidl_security
import fidl_fuchsia_wlan_sme as f_wlan_sme
import fuchsia_wlan_base_test
import honeydew.affordances.connectivity.wlan.core as wlan_core
from legacy_access_point.access_point import AccessPoint, setup_ap
from legacy_access_point.ap_lib.hostapd_constants import (
AP_DEFAULT_CHANNEL_2G,
AP_SSID_LENGTH_2G,
)
from legacy_access_point.ap_lib.hostapd_security import (
Security as DeprecatedSecurity,
)
from mobly import asserts, signals, test_runner
from mobly.asserts import fail
from openwrt_access_point.lib.access_point_config import (
DEFAULT_2G_CHANNEL,
AccessPointConfig,
BssSettings,
RadioConfig,
Security,
SecurityOpen,
SecurityWpa2,
)
from openwrt_access_point.lib.access_point_config_mapper import (
AccessPointConfigMapper as ConfigMapper,
)
logger = logging.getLogger(__name__)
class AssociationRejectionTest(fuchsia_wlan_base_test.FuchsiaWlanBaseTest):
"""Tests that client interfaces properly handle AP association rejection.
When an AP rejects association (e.g. max_num_sta=0), the WLAN core SME and
driver must report connection failure and not enter a false connected state.
"""
phy: wlan_core.Phy
async def setup_class(self) -> None:
await super().setup_class()
self.phy = await self.dut.wlan_core.ensure_single_phy()
async def setup_test(self) -> None:
await super().setup_test()
await self.dut.wlan_core.destroy_all_ifaces()
if self.access_point:
self.access_point.stop_all_aps()
async def teardown_test(self) -> None:
if self.access_point:
self.access_point.stop_all_aps()
await super().teardown_test()
async def pre_run(self) -> None:
self.generate_tests(
test_logic=self._test_logic,
name_func=self.name_func,
arg_sets=[
(SecurityOpen(), None),
(SecurityWpa2(), AccessPointConfig.random_string(13)),
],
)
def name_func(self, security: Security, password: str | None) -> str:
match security:
case SecurityOpen():
sec_name = "open"
case SecurityWpa2():
sec_name = "wpa2"
case _:
fail(f"Unrecognized security type: {security}")
return f"test_reject_association_{sec_name}"
async def _connect_and_get_result(
self,
iface: wlan_core.ClientIface,
ssid: str,
bss_desc: f_wlan_ieee80211.BssDescription,
authentication: fidl_security.Authentication,
) -> f_wlan_sme.ConnectResult:
client, server = iface._fc_transport.channel_create()
connect_txn_client = f_wlan_sme.ConnectTransactionClient(client.take())
req = f_wlan_sme.ConnectRequest(
ssid=list(ssid.encode("utf-8")),
bss_description=bss_desc,
multiple_bss_candidates=False,
authentication=authentication,
deprecated_scan_type=f_wlan_common.ScanType.ACTIVE,
)
event_handler = wlan_core.ConnectTransactionEventHandler(
connect_txn_client
)
event_handler_task = asyncio.create_task(event_handler.serve())
try:
iface.client_sme.connect(req=req, txn=server.take())
on_connect_result_req = await asyncio.wait_for(
event_handler.txn_queue.get(), timeout=30
)
assert isinstance(
on_connect_result_req,
f_wlan_sme.ConnectTransactionOnConnectResultRequest,
)
return on_connect_result_req.result
finally:
event_handler_task.cancel()
try:
await event_handler_task
except asyncio.CancelledError:
pass
def _derive_authentication(
self, security: Security, password: str | None
) -> fidl_security.Authentication:
if isinstance(security, SecurityOpen):
return fidl_security.Authentication(
protocol=fidl_security.Protocol.OPEN,
credentials=None,
)
elif isinstance(security, SecurityWpa2):
if password is None:
raise signals.TestError("Password is required for WPA2")
return fidl_security.Authentication(
protocol=fidl_security.Protocol.WPA2_PERSONAL,
credentials=fidl_security.Credentials(
wpa=fidl_security.WpaCredentials(
passphrase=list(password.encode("utf-8"))
)
),
)
else:
raise signals.TestError(f"Unsupported security mode: {security}")
async def _test_logic(
self, security: Security, password: str | None
) -> None:
iface = await self.phy.create_client_iface()
ssid = AccessPointConfig.random_string(AP_SSID_LENGTH_2G)
if self.openwrt_ap:
self.openwrt_ap.configure_wifi(
AccessPointConfig(
radios=[
RadioConfig(
channel=DEFAULT_2G_CHANNEL,
bss_settings=[
BssSettings(
ssid=ssid,
password=password,
security=security,
custom_uci_options={"max_num_sta": 0},
)
],
)
]
)
)
elif isinstance(self.access_point, AccessPoint):
setup_ap(
access_point=self.access_point,
profile_name="whirlwind",
channel=AP_DEFAULT_CHANNEL_2G,
ssid=ssid,
security=DeprecatedSecurity(
security_mode=ConfigMapper.to_hostapd_security(security),
password=password,
wpa_strict_rekey=False,
),
additional_ap_parameters={"max_num_sta": 0},
)
authentication = self._derive_authentication(security, password)
logger.info(
f"Scanning for {ssid} and connecting, expecting AP to reject association"
)
target_channel = (
DEFAULT_2G_CHANNEL.number
if self.openwrt_ap
else AP_DEFAULT_CHANNEL_2G
)
scan_results = await iface.passive_scan(channels=[target_channel])
asserts.assert_in(
ssid,
scan_results,
f"Failed to find BSS with SSID {ssid} in scan results: {scan_results}",
)
target_bss_desc = scan_results[ssid][0]
result = await self._connect_and_get_result(
iface=iface,
ssid=ssid,
bss_desc=target_bss_desc,
authentication=authentication,
)
logger.info(f"Received ConnectResult from SME: {result}")
asserts.assert_in(
result.code,
[
f_wlan_ieee80211.StatusCode.DENIED_NO_MORE_STAS,
f_wlan_ieee80211.StatusCode.REFUSED_REASON_UNSPECIFIED,
f_wlan_ieee80211.StatusCode.REFUSED_UNAUTHENTICATED_ACCESS_NOT_SUPPORTED,
],
f"Expected rejection status code, got: {result.code}",
)
status = await iface.status()
asserts.assert_false(
bool(status.connected),
f"Expected interface not to be connected, but got status: {status}",
)
if __name__ == "__main__":
test_runner.main()