diff --git a/tests/nexus/CMakeLists.txt b/tests/nexus/CMakeLists.txt index 83ebc93df..209163f64 100644 --- a/tests/nexus/CMakeLists.txt +++ b/tests/nexus/CMakeLists.txt @@ -282,6 +282,7 @@ ot_nexus_test(1_3_SRPC_TC_4 "cert;nexus") ot_nexus_test(1_3_SRPC_TC_5 "cert;nexus") ot_nexus_test(1_3_SRPC_TC_7 "cert;nexus") ot_nexus_test(1_3_GEN_TC_1 "cert;nexus") +ot_nexus_test(1_3_GEN_TC_2 "cert;nexus") ot_nexus_test(1_3_DIAG_TC_1 "cert;nexus") ot_nexus_test(1_3_DIAG_TC_2 "cert;nexus") ot_nexus_test(1_4_TREL_TC_1 "cert;nexus") diff --git a/tests/nexus/run_nexus_tests.sh b/tests/nexus/run_nexus_tests.sh index c0e27749e..0cc36ee9e 100755 --- a/tests/nexus/run_nexus_tests.sh +++ b/tests/nexus/run_nexus_tests.sh @@ -217,6 +217,7 @@ DEFAULT_TESTS=( "1_3_SRPC_TC_5" "1_3_SRPC_TC_7" "1_3_GEN_TC_1" + "1_3_GEN_TC_2" "1_3_DIAG_TC_1" "1_3_DIAG_TC_2" "1_4_TREL_TC_1" diff --git a/tests/nexus/test_1_3_GEN_TC_2.cpp b/tests/nexus/test_1_3_GEN_TC_2.cpp new file mode 100644 index 000000000..6f253b7bf --- /dev/null +++ b/tests/nexus/test_1_3_GEN_TC_2.cpp @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2026, The OpenThread Authors. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * 1. Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * 3. Neither the name of the copyright holder nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#include +#include + +#include "platform/nexus_core.hpp" +#include "platform/nexus_node.hpp" + +namespace ot { +namespace Nexus { + +/** + * Time to advance for a node to form a network and become leader, in milliseconds. + */ +static constexpr uint32_t kFormNetworkTime = 13 * 1000; + +/** + * Time to advance for the BR to perform automatic actions (RA, Network Data), in milliseconds. + */ +static constexpr uint32_t kBrActionTime = 20 * 1000; + +/** + * Time to advance for DNS query processing, in milliseconds. + */ +static constexpr uint32_t kDnsQueryTime = 20 * 1000; + +/** + * Infrastructure interface index. + */ +static constexpr uint32_t kInfraIfIndex = 1; + +/** + * Active Timestamp components. + */ +static constexpr uint64_t kActiveTimestampSeconds = 0x666af1; +static constexpr uint16_t kActiveTimestampTicks = 0x1880; + +/** + * Service name parameters. + */ +static const char kMeshCoPServiceType[] = "_meshcop._udp"; + +void Test_1_3_GEN_TC_2(const char *aJsonFileName) +{ + /** + * 9.2. [1.3] [CERT] Border Router mDNS discovery of v1.3/v1.4 TXT records + * + * 9.2.1. Purpose + * - This test verifies that the Border Router DUT implements TXT records that were added in the v1.3 / v1.4 + * specifications: “omr”, “xa”, and “id”. It also verifies changed rules in the v1.4.0 specification on use of + * vendor-specific records. It also validates, to the extent possible, that BR vendors provide correct values + * for TXT record keys. + * + * 9.2.2. Topology + * - BR_1: Thread Border Router DUT and Leader + * - Eth_1: Reference device + * + * Spec Reference | V1.3.0 Section + * ---------------|--------------- + * Thread 1.3.0 | 9.2 + */ + + Core nexus; + + Node &br1 = nexus.CreateNode(); + Node ð1 = nexus.CreateNode(); + + br1.SetName("BR_1"); + eth1.SetName("Eth_1"); + + SuccessOrQuit(Instance::SetGlobalLogLevel(kLogLevelNote)); + + br1.mSettings.Wipe(); + eth1.mSettings.Wipe(); + br1.Reset(); + eth1.Reset(); + + for (uint8_t i = 0; i < 2; i++) + { + Log("---------------------------------------------------------------------------------------"); + Log("Iteration %u", i); + + /** + * Step 0 + * - Device: BR_1 (DUT) / Harness + * - Description (GEN-9.2): Harness configures DUT with Active Operational Dataset, either via THCI or via + * the vendor specified setup method for the BR. This includes the following: Network Name: "Thread Cert + * 9.2", Active Timestamp (uint64): 0x000000666AF13100. Note: this timestamp can be set using the OT CLI + * command "dataset activetimestamp 1718284593" + */ + Log("Step 0: Harness configures DUT with Active Operational Dataset."); + { + MeshCoP::Dataset::Info datasetInfo; + SuccessOrQuit(br1.Get().CreateNewNetwork(datasetInfo)); + + datasetInfo.mActiveTimestamp.mSeconds = kActiveTimestampSeconds; + datasetInfo.mActiveTimestamp.mTicks = kActiveTimestampTicks; + datasetInfo.mActiveTimestamp.mAuthoritative = false; + datasetInfo.mComponents.mIsActiveTimestampPresent = true; + + { + MeshCoP::NetworkName networkName; + SuccessOrQuit(networkName.Set("Thread Cert 9.2")); + datasetInfo.Set(networkName); + } + + br1.Get().SaveLocal(datasetInfo); + + SuccessOrQuit(br1.Get().SetServiceBaseName("OpenThread ")); + + { + uint8_t vendorTxtData[] = { + 14, 'v', 'n', '=', 'N', 'e', 'x', 'u', 's', 'V', 'e', 'n', 'd', 'o', 'r', 13, 'm', 'n', + '=', 'N', 'e', 'x', 'u', 's', 'M', 'o', 'd', 'e', 'l', 6, 'v', 'o', '=', 0x00, 0x00, 0x01, + }; + br1.Get().SetVendorData(vendorTxtData, sizeof(vendorTxtData)); + } + + br1.Get().SetEnabled(true); + } + + /** + * Step 1 + * - Device: BR_1 (DUT) + * - Description (GEN-9.2): Form topology. DUT becomes Leader of its Thread Network. It configures an OMR ULA + * prefix OMR_1 and sends the prefix OMR_1 in ND RA multicast messages on the AIL. Harness records the + * value of OMR_1 for validation in future test steps. + */ + Log("Step 1: Form topology. DUT becomes Leader. Configures OMR prefix."); + br1.Get().Up(); + SuccessOrQuit(br1.Get().Start()); + + br1.Get().Init(kInfraIfIndex, true); + br1.Get().Init(); + SuccessOrQuit(br1.Get().SetEnabled(true)); + nexus.AdvanceTime(kFormNetworkTime); + + // Wait for BR to advertise prefixes and for verification script to catch RAs + nexus.AdvanceTime(kBrActionTime); + + VerifyOrQuit(br1.Get().IsLeader()); + + SuccessOrQuit(eth1.Get().SetEnabled(true, kInfraIfIndex)); + + /** + * Step 2 + * - Device: Eth_1 + * - Description (GEN-9.2): Harness instructs the device to send via mDNS a DNS-SD QType=PTR query for + * services “_meshcop._udp.local”. + */ + Log("Step 2: Eth_1 sends mDNS PTR query for _meshcop._udp.local."); + Dns::Multicast::Core::Browser browser; + browser.mCallback = [](otInstance *, const Dns::Multicast::Core::BrowseResult *) {}; + browser.mServiceType = kMeshCoPServiceType; + browser.mInfraIfIndex = kInfraIfIndex; + SuccessOrQuit(eth1.Get().StartBrowser(browser)); + nexus.AdvanceTime(kDnsQueryTime); + SuccessOrQuit(eth1.Get().StopBrowser(browser)); + + /** + * Step 3 + * - Device: BR_1 (DUT) + * - Description (GEN-9.2): Automatically responds with mDNS response with PTR records, and optionally + * Additional Records (SRV/TXT). + */ + Log("Step 3: BR_1 (DUT) responds with mDNS response with PTR records."); + + /** + * Step 4 + * - Device: Eth_1 + * - Description (GEN-9.2): Harness instructs the device to send via mDNS a query QType=TXT, with the DNS + * name set to the service instance name that is included in the PTR record in step 3. + */ + Log("Step 4: Eth_1 sends mDNS TXT query for the service instance."); + { + Dns::Multicast::Core::TxtResolver resolver; + char serviceInstance[64]; + + snprintf(serviceInstance, sizeof(serviceInstance), "OpenThread%s", + br1.Get().GetExtAddress().ToString().AsCString()); + + Log("Querying TXT for instance: %s", serviceInstance); + + resolver.mCallback = [](otInstance *, const Dns::Multicast::Core::TxtResult *aResult) { + Log("TXT Callback: instance=%s", aResult->mServiceInstance); + }; + resolver.mServiceType = kMeshCoPServiceType; + resolver.mInfraIfIndex = kInfraIfIndex; + resolver.mServiceInstance = serviceInstance; + + SuccessOrQuit(eth1.Get().StartTxtResolver(resolver)); + nexus.AdvanceTime(kDnsQueryTime); + SuccessOrQuit(eth1.Get().StopTxtResolver(resolver)); + } + + /** + * Step 5 + * - Device: BR_1 (DUT) + * - Description (GEN-9.2): Automatically responds with mDNS response with TXT record. + */ + Log("Step 5: BR_1 (DUT) responds with mDNS response with TXT record."); + + /** + * Step 6 + * - Device: BR_1 (DUT) / Harness + * - Description (GEN-9.2): Verify that the DUT responded with the right data in the TXT record keys. This + * uses the TXT record found in step 5. + */ + Log("Step 6: Verify TXT record keys."); + + { + char varName[64]; + snprintf(varName, sizeof(varName), "BR_1_EXT_ADDR_%u", i); + nexus.AddTestVar(varName, br1.Get().GetExtAddress().ToString().AsCString()); + + Ip6::Prefix omrPrefix; + BorderRouter::RoutePreference preference; + if (br1.Get().GetFavoredOmrPrefix(omrPrefix, preference) == kErrorNone) + { + snprintf(varName, sizeof(varName), "BR_1_OMR_PREFIX_%u", i); + nexus.AddTestVar(varName, omrPrefix.ToString().AsCString()); + Log("Iteration %u OMR Prefix: %s", i, omrPrefix.ToString().AsCString()); + } + } + + if (i == 0) + { + /** + * Step 7 + * - Device: BR_1 (DUT) + * - Description (GEN-9.2): Factory reset the device. + */ + Log("Step 7: Factory reset the device."); + SuccessOrQuit(br1.Get().SetEnabled(false)); + br1.mSettings.Wipe(); + br1.Reset(); + + SuccessOrQuit(eth1.Get().SetEnabled(false, kInfraIfIndex)); + + // Wait for reset and restart + nexus.AdvanceTime(20 * 1000); + } + else + { + /** + * Step 8 + * - Device: BR_1 (DUT) + * - Description (GEN-9.2): Repeat steps 0 to 6 again. + */ + Log("Step 8: Repeat steps 0 to 6 again."); + } + } + + Log("All steps completed."); + + nexus.SaveTestInfo(aJsonFileName); +} + +} // namespace Nexus +} // namespace ot + +int main(int argc, char *argv[]) +{ + ot::Nexus::Test_1_3_GEN_TC_2((argc > 2) ? argv[2] : "test_1_3_gen_tc_2.json"); + printf("All tests passed\n"); + return 0; +} diff --git a/tests/nexus/verify_1_3_GEN_TC_2.py b/tests/nexus/verify_1_3_GEN_TC_2.py new file mode 100644 index 000000000..58044932a --- /dev/null +++ b/tests/nexus/verify_1_3_GEN_TC_2.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2026, The OpenThread Authors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +# + +import verify_utils +import ipaddress + + +def parse_txt(txt_records): + txt_dict = {} + for entry in txt_records: + if not entry: + continue + try: + # entry is expected to be a Bytes object (from pktverify) + if b'=' in entry: + k, v = entry.split(b'=', 1) + txt_dict[k.decode('ascii', errors='ignore')] = v + else: + txt_dict[entry.decode('ascii', errors='ignore')] = None + except Exception: + continue + return txt_dict + + +def verify(pv): + # Spec Reference | V1.3.0 Section + # ---------------|--------------- + # Thread 1.3.0 | 9.2 + + agent_ids = [] + + last_idx = (0, 0) + pkts = pv.pkts + + for i in range(2): + print(f"Iteration {i}") + + BR_1_EXT_ADDR = pv.vars[f'BR_1_EXT_ADDR_{i}'].replace(':', '').lower() + BR_1_OMR_PREFIX = pv.vars[f'BR_1_OMR_PREFIX_{i}'] + expected_pfx = ipaddress.IPv6Network(BR_1_OMR_PREFIX) + # OMR value is 8 bytes of the network address in hex + expected_omr_value = expected_pfx.network_address.packed[:8].hex().lower() + + print(f"Iteration {i} Expected ExtAddr: {BR_1_EXT_ADDR}") + print(f"Iteration {i} Expected OMR Value: {expected_omr_value}") + + print("Step 1: Form topology. DUT becomes Leader. Configures OMR prefix.") + + def match_ra(p): + if not (hasattr(p, 'icmpv6') and p.icmpv6.type == 134): + return False + if not hasattr(p.icmpv6, 'opt') or not hasattr(p.icmpv6.opt, 'prefix'): + return False + for pfx in p.icmpv6.opt.prefix: + try: + actual_pfx = ipaddress.IPv6Network(str(pfx) + '/64', strict=False) + if actual_pfx.network_address == expected_pfx.network_address: + return True + except ValueError: + continue + return False + + ra = pkts.range(last_idx).filter(match_ra).must_next() + print(f"Recorded OMR prefix: {BR_1_OMR_PREFIX} (found in packet {ra.number})") + last_idx = pkts.index + + print("Step 2: Eth_1 sends mDNS PTR query for _meshcop._udp.local.") + ptr_query = pkts.range(last_idx).filter(lambda p: (hasattr(p, 'mdns') and p.mdns.flags.response == 0 and any( + '_meshcop._udp.local' in name for name in p.mdns.qry.name))).must_next() + last_idx = pkts.index + + print("Step 3: BR_1 (DUT) responds with mDNS response with PTR records.") + ptr_resp = pkts.range(last_idx).filter(lambda p: (hasattr(p, 'mdns') and p.mdns.flags.response == 1 and any( + '_meshcop._udp.local' in name for name in p.mdns.resp.name))).must_next() + last_idx = pkts.index + + print("Step 5: BR_1 (DUT) responds with mDNS response with TXT record.") + + txt_resp = None + for pkt in pkts.range(last_idx): + if not (hasattr(pkt, 'mdns') and pkt.mdns.flags.response == 1 and hasattr(pkt.mdns, 'txt')): + continue + + txt_dict = parse_txt(pkt.mdns.txt) + if 'omr' in txt_dict and txt_dict['omr'].hex().endswith(expected_omr_value): + txt_resp = pkt + break + + assert txt_resp is not None, f"Could not find TXT response for iteration {i} with OMR value {expected_omr_value}" + print(f"Found TXT response in packet {txt_resp.number}") + last_idx = (txt_resp.number, txt_resp.number) + + print("Step 6: Verify TXT record keys.") + txt_dict = parse_txt(txt_resp.mdns.txt) + + # omr: First byte 0x40, the rest is OMR prefix 8 bytes. Total 9 bytes. + assert 'omr' in txt_dict, "omr TXT record missing" + omr_val = txt_dict['omr'] + assert omr_val[0] == 0x40, f"omr value first byte should be 0x40, got {omr_val[0]}" + assert omr_val[1:].hex( + ) == expected_omr_value, f"omr value mismatch, expected {expected_omr_value}, got {omr_val[1:].hex()}" + assert len(omr_val) == 9, f"omr value length should be 9 bytes, got {len(omr_val)}" + + # id: Random number (16 bytes) + assert 'id' in txt_dict, "id TXT record missing" + agent_id = txt_dict['id'].hex() + assert len(agent_id) == 32, f"id length should be 16 bytes, got {len(agent_id)//2}" + agent_ids.append(agent_id) + + # nn: "Thread Cert 9.2" + assert 'nn' in txt_dict, "nn TXT record missing" + nn_val = txt_dict['nn'].decode('ascii') + assert nn_val == "Thread Cert 9.2", f"nn value mismatch, expected 'Thread Cert 9.2', got '{nn_val}'" + + # tv: "1.3.0" or "1.4.0" + assert 'tv' in txt_dict, "tv TXT record missing" + tv_val = txt_dict['tv'].decode('ascii') + assert tv_val in ["1.3.0", "1.4.0"], f"tv value mismatch, expected '1.3.0' or '1.4.0', got '{tv_val}'" + + # sb: Bit 9 and 10 MUST be '1'. + assert 'sb' in txt_dict, "sb TXT record missing" + # sb is 4 bytes. + sb_val = int.from_bytes(txt_dict['sb'], 'big') + assert (sb_val & 0x00000600) == 0x00000600, f"sb bits 9 and 10 must be '1', got {hex(sb_val)}" + + # xa: MAC Extended Address + assert 'xa' in txt_dict, "xa TXT record missing" + xa_val = txt_dict['xa'].hex() + assert xa_val == BR_1_EXT_ADDR, f"xa value mismatch, expected {BR_1_EXT_ADDR}, got {xa_val}" + + # at: Active Timestamp 0x000000666af13100 + assert 'at' in txt_dict, "at TXT record missing" + at_val = txt_dict['at'].hex() + assert at_val == '000000666af13100', f"at value mismatch, expected '000000666af13100', got '{at_val}'" + + # vn: "NexusVendor" + assert 'vn' in txt_dict, "vn TXT record missing" + vn_val = txt_dict['vn'].decode('ascii') + assert vn_val == "NexusVendor", f"vn value mismatch, expected 'NexusVendor', got '{vn_val}'" + + # mn: "NexusModel" + assert 'mn' in txt_dict, "mn TXT record missing" + mn_val = txt_dict['mn'].decode('ascii') + assert mn_val == "NexusModel", f"mn value mismatch, expected 'NexusModel', got '{mn_val}'" + + # vo: 0x000001 + assert 'vo' in txt_dict, "vo TXT record missing" + vo_val = txt_dict['vo'].hex() + assert vo_val == '000001', f"vo value mismatch, expected '000001', got '{vo_val}'" + + if i == 1: + assert agent_ids[0] != agent_ids[1], f"agent id should be different after factory reset: {agent_ids}" + + print("Verification SUCCESS") + + +if __name__ == '__main__': + verify_utils.run_main(verify)