blob: 608cfd8b4b00a8cd5477ed9f672d2fcb40fdd9ac [file]
/*
*
* Copyright (c) 2026 Project CHIP Authors
* All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "CommissioningProxyMockTimer.h"
#include "CommissioningProxyMockTransport.h"
#include <app/clusters/commissioning-proxy-server/CommissioningProxyCluster.h>
#include <app/clusters/commissioning-proxy-server/tests/CommissioningProxyMockTransport.h>
#include <platform/CommissionableDataProvider.h> // for kMaxDiscriminatorValue
#include <pw_unit_test/framework.h>
#include <app/data-model-provider/tests/ReadTesting.h>
#include <app/server-cluster/testing/ClusterTester.h>
#include <app/server-cluster/testing/TestServerClusterContext.h>
#include <app/server-cluster/testing/ValidateGlobalAttributes.h>
#include <clusters/CommissioningProxy/Attributes.h>
#include <clusters/CommissioningProxy/Commands.h>
#include <clusters/CommissioningProxy/Metadata.h>
#include <system/SystemClock.h>
using namespace chip;
using namespace chip::app;
using namespace chip::app::Clusters;
using namespace chip::app::Clusters::CommissioningProxy;
using namespace chip::app::Clusters::CommissioningProxy::Attributes;
using namespace chip::app::Clusters::CommissioningProxy::Commands;
using namespace chip::Testing;
using chip::Protocols::InteractionModel::ClusterStatusCode;
namespace {
constexpr EndpointId kTestEndpointId = 1;
struct TestCommissioningProxyCluster : public ::testing::Test
{
// No stack init needed: every cluster timer goes through the mockTimer below, so
// nothing under test touches the system layer or the event loop.
static void SetUpTestSuite() { ASSERT_EQ(chip::Platform::MemoryInit(), CHIP_NO_ERROR); }
static void TearDownTestSuite() { chip::Platform::MemoryShutdown(); }
void SetUp() override {}
// Drives the session manager's response timeout and the aggregator's scan watchdog:
// virtual time, so expiry costs no wall-clock, plus one-shot StartTimer failure
// injection for the rollback paths.
CommissioningProxyMockTimer mockTimer;
// Mock transports available to every test. A transport is "supported" (advertised
// in the Transport attribute) iff it is registered, so RegisterMocks() makes both
// BLE and Wi-Fi PAF available; tests that need only one register it directly.
CommissioningProxyMockTransport mockBle{ CapabilitiesBitmap::kBle };
CommissioningProxyMockTransport mockPaf{ CapabilitiesBitmap::kWiFiPAF };
void RegisterMocks(CommissioningProxyCluster & cluster)
{
cluster.RegisterTransport(mockBle);
cluster.RegisterTransport(mockPaf);
}
};
namespace CPAttributes = chip::app::Clusters::CommissioningProxy::Attributes;
// Helper: read the Transport attribute (CapabilitiesBitmap) — the runtime
// source of truth for which transports the proxy supports. A transport bit
// is set here iff the cluster's GetSupportedTransports() returns it for the
// current build flag / feature flag combination.
static chip::BitMask<CapabilitiesBitmap> ReadSupportedTransports(ClusterTester & tester)
{
chip::BitMask<CapabilitiesBitmap> supported;
EXPECT_EQ(tester.ReadAttribute(CPAttributes::Transport::Id, supported), CHIP_NO_ERROR);
return supported;
}
// Convenience: skip a test when the named transport is absent from the Transport
// attribute (e.g. PAF-only test running against a BLE-only build of the cluster).
#define SKIP_IF_TRANSPORT_UNSUPPORTED(tester, transport) \
do \
{ \
if (!ReadSupportedTransports(tester).Has(transport)) \
{ \
GTEST_SKIP() << "Transport " #transport " not advertised by this build of CommissioningProxyCluster"; \
} \
} while (0)
// Convenience: skip a test that needs more than one concurrent session when this build
// reserves fewer. CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS sizes the session table
// at compile time and the default is the spec minimum of 1, so the multi-session paths
// (per-fabric isolation, distinct session ids, staying Connected until the last session
// closes) are only reachable in a build that raises it.
#define SKIP_IF_MAX_SESSIONS_BELOW(needed) \
do \
{ \
if (CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS < (needed)) \
{ \
GTEST_SKIP() << "Build reserves " << CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS \
<< " proxy session(s), this case needs " << (needed); \
} \
} while (0)
// =============================================================================
// Feature Tests
// =============================================================================
TEST_F(TestCommissioningProxyCluster, TestFeatures)
{
TestServerClusterContext context;
// No features - only mandatory attributes
{
BitMask<Feature> noFeatures;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
EXPECT_TRUE(IsAttributesListEqualTo(cluster,
{
CPAttributes::Transport::kMetadataEntry,
CPAttributes::ScanMaxTime::kMetadataEntry,
CPAttributes::MaxSessions::kMetadataEntry,
}));
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
CHIP_NO_ERROR);
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
EXPECT_EQ(expectedCommandsBuilder.AppendElements({
ProxyConnectRequest::kMetadataEntry,
ProxyDisconnectRequest::kMetadataEntry,
ProxyScanRequest::kMetadataEntry,
ProxyMessageRequest::kMetadataEntry,
}),
CHIP_NO_ERROR);
EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Background Scan(BGS) Feature - BGS and mandatory attributes
{
BitMask<Feature> features(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
EXPECT_TRUE(IsAttributesListEqualTo(cluster,
{
CPAttributes::Transport::kMetadataEntry,
CPAttributes::ScanMaxTime::kMetadataEntry,
CPAttributes::MaxSessions::kMetadataEntry,
CPAttributes::MaxCachedResults::kMetadataEntry,
CPAttributes::NumCachedResults::kMetadataEntry,
CPAttributes::CacheTimeout::kMetadataEntry,
CPAttributes::CachedResults::kMetadataEntry,
}));
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
CHIP_NO_ERROR);
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
EXPECT_EQ(expectedCommandsBuilder.AppendElements({
ProxyConnectRequest::kMetadataEntry,
ProxyDisconnectRequest::kMetadataEntry,
ProxyScanRequest::kMetadataEntry,
ProxyBackGroundScanStartRequest::kMetadataEntry,
ProxyBackGroundScanStopRequest::kMetadataEntry,
ProxyMessageRequest::kMetadataEntry,
}),
CHIP_NO_ERROR);
EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// WiFi Feature - mandatory attributes plus WiFiBand. WiFiBand is [WI]
// (optional under WI); this implementation always exposes it when WI is set.
{
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
EXPECT_TRUE(IsAttributesListEqualTo(cluster,
{
CPAttributes::Transport::kMetadataEntry,
CPAttributes::ScanMaxTime::kMetadataEntry,
CPAttributes::MaxSessions::kMetadataEntry,
CPAttributes::WiFiBand::kMetadataEntry,
}));
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
CHIP_NO_ERROR);
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
EXPECT_EQ(expectedCommandsBuilder.AppendElements({
ProxyConnectRequest::kMetadataEntry,
ProxyDisconnectRequest::kMetadataEntry,
ProxyScanRequest::kMetadataEntry,
ProxyMessageRequest::kMetadataEntry,
}),
CHIP_NO_ERROR);
EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// All Features - All attributes
{
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
EXPECT_TRUE(IsAttributesListEqualTo(cluster,
{
CPAttributes::Transport::kMetadataEntry,
CPAttributes::ScanMaxTime::kMetadataEntry,
CPAttributes::MaxSessions::kMetadataEntry,
CPAttributes::MaxCachedResults::kMetadataEntry,
CPAttributes::NumCachedResults::kMetadataEntry,
CPAttributes::CacheTimeout::kMetadataEntry,
CPAttributes::CachedResults::kMetadataEntry,
CPAttributes::WiFiBand::kMetadataEntry,
}));
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> commandsBuilder;
EXPECT_EQ(cluster.AcceptedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), commandsBuilder),
CHIP_NO_ERROR);
ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> expectedCommandsBuilder;
EXPECT_EQ(expectedCommandsBuilder.AppendElements({
ProxyConnectRequest::kMetadataEntry,
ProxyDisconnectRequest::kMetadataEntry,
ProxyScanRequest::kMetadataEntry,
ProxyBackGroundScanStartRequest::kMetadataEntry,
ProxyBackGroundScanStopRequest::kMetadataEntry,
ProxyMessageRequest::kMetadataEntry,
}),
CHIP_NO_ERROR);
EXPECT_TRUE(EqualAcceptedCommandSets(commandsBuilder.TakeBuffer(), expectedCommandsBuilder.TakeBuffer()));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
}
// =============================================================================
// Startup Tests
// =============================================================================
TEST_F(TestCommissioningProxyCluster, TestStartupSucceeds)
{
TestServerClusterContext context;
BitMask<Feature> noFeatures;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// Attribute Tests
// =============================================================================
TEST_F(TestCommissioningProxyCluster, TestMandatoryAttributes)
{
TestServerClusterContext context;
BitMask<Feature> noFeatures;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// ScanMaxTime has no spec fallback; the cluster uses 10 s as a practical default.
// (MaxSessions is read in TestMaxSessionsAttributeReadsFromBuildConfig, which checks it
// against the build constant rather than the cluster's own getter.)
uint8_t scanMaxTime = 0;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::ScanMaxTime::Id, scanMaxTime), CHIP_NO_ERROR);
EXPECT_EQ(scanMaxTime, 10);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// The Transport attribute SHALL advertise exactly the transports that have a
// registered driver. kBle is not gated by any Feature bit per spec.
TEST_F(TestCommissioningProxyCluster, TestTransportAttribute_ReflectsRegisteredTransports)
{
TestServerClusterContext context;
// Register only BLE: Transport advertises kBle, not kWiFiPAF.
{
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
cluster.RegisterTransport(mockBle);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
auto supported = ReadSupportedTransports(tester);
EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kBle));
EXPECT_FALSE(supported.Has(CapabilitiesBitmap::kWiFiPAF));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Register only Wi-Fi PAF: Transport advertises kWiFiPAF, not kBle. WiFiPAF is
// independent of the WI feature (WI only gates WiFiBand), so no feature is set.
{
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
cluster.RegisterTransport(mockPaf);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
auto supported = ReadSupportedTransports(tester);
EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kWiFiPAF));
EXPECT_FALSE(supported.Has(CapabilitiesBitmap::kBle));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Register both: Transport advertises both.
{
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
auto supported = ReadSupportedTransports(tester);
EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kBle));
EXPECT_TRUE(supported.Has(CapabilitiesBitmap::kWiFiPAF));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
}
// MaxSessions attribute must reflect the configured GetMaxSessions() value,
// not a hardcoded constant.
TEST_F(TestCommissioningProxyCluster, TestMaxSessionsAttributeReadsFromBuildConfig)
{
TestServerClusterContext context;
BitMask<Feature> noFeatures;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(noFeatures), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// MaxSessions is Fixed quality: it is the build-time pool size, not per-instance config.
uint8_t maxSessions = 0;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::MaxSessions::Id, maxSessions), CHIP_NO_ERROR);
EXPECT_EQ(maxSessions, CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// Spec-compliance attribute tests
// =============================================================================
// These tests directly assert spec-mandated attribute defaults and access
// semantics from src/app_clusters/CommissioningProxy.adoc (§ Attributes).
// ClusterRevision SHALL equal the highest value in the Revision History table
// (currently 1). Source: zzz_generated/.../Metadata.h kRevision.
TEST_F(TestCommissioningProxyCluster, TestClusterRevisionEqualsOne)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t clusterRevision = 0;
ASSERT_EQ(tester.ReadAttribute(chip::app::Clusters::Globals::Attributes::ClusterRevision::Id, clusterRevision), CHIP_NO_ERROR);
EXPECT_EQ(clusterRevision, 1u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// FeatureMap SHALL reflect the feature bits the cluster was constructed with.
TEST_F(TestCommissioningProxyCluster, TestFeatureMapReflectsConfig)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface, Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint32_t featureMap = 0;
ASSERT_EQ(tester.ReadAttribute(chip::app::Clusters::Globals::Attributes::FeatureMap::Id, featureMap), CHIP_NO_ERROR);
EXPECT_EQ(featureMap, static_cast<uint32_t>(Feature::kWiFiNetworkInterface) | static_cast<uint32_t>(Feature::kBackgroundScan));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § Attributes: Transport, MaxSessions, MaxCachedResults and NumCachedResults are
// "R V" and WiFiBand is "F R V" — every one of them read-only, so a write SHALL be
// rejected with UnsupportedWrite. Every feature is enabled here so all five are present.
TEST_F(TestCommissioningProxyCluster, TestReadOnlyAttributes_WriteRejected)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface, Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
auto expectRejected = [&tester](const char * name, AttributeId id, const auto & value) {
auto status = tester.WriteAttribute(id, value);
EXPECT_FALSE(status.IsSuccess()) << name;
EXPECT_EQ(status.GetStatusCode().GetStatus(), Protocols::InteractionModel::Status::UnsupportedWrite) << name;
};
expectRejected("Transport", CPAttributes::Transport::Id, chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kBle));
expectRejected("MaxSessions", CPAttributes::MaxSessions::Id, static_cast<uint8_t>(5));
expectRejected("MaxCachedResults", CPAttributes::MaxCachedResults::Id, static_cast<uint8_t>(5));
expectRejected("NumCachedResults", CPAttributes::NumCachedResults::Id, static_cast<uint8_t>(3));
expectRejected("WiFiBand", CPAttributes::WiFiBand::Id, chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § Attributes: ScanMaxTime and CacheTimeout both carry constraint "min 1", so
// writing 0 to either SHALL be rejected with ConstraintError.
TEST_F(TestCommissioningProxyCluster, TestWritableAttributes_WriteZeroConstraintError)
{
TestServerClusterContext context;
BitMask<Feature> bgs(Feature::kBackgroundScan); // CacheTimeout is BGS-only
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
EXPECT_EQ(tester.WriteAttribute(CPAttributes::ScanMaxTime::Id, static_cast<uint8_t>(0)).GetStatusCode().GetStatus(),
Protocols::InteractionModel::Status::ConstraintError);
EXPECT_EQ(tester.WriteAttribute(CPAttributes::CacheTimeout::Id, static_cast<uint16_t>(0)).GetStatusCode().GetStatus(),
Protocols::InteractionModel::Status::ConstraintError);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § Attributes: ScanMaxTime access = "RW VO". Storage and change reporting are owned
// by the cluster: a write that changes the value SHALL emit a change report and be
// visible to the next read, and a write of the unchanged value SHALL NOT report.
TEST_F(TestCommissioningProxyCluster, TestScanMaxTimeAttribute_WritableAndChangeReporting)
{
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
// Start with the tester's context so change notifications land in the dirty
// list the tester observes.
ClusterTester tester(cluster);
EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
// Writing the current value (default 10) is a no-op: success, no change report.
EXPECT_TRUE(tester.WriteAttribute(CPAttributes::ScanMaxTime::Id, static_cast<uint8_t>(10)).IsSuccess());
EXPECT_FALSE(tester.IsAttributeDirty(CPAttributes::ScanMaxTime::Id));
// Writing a new value reports the change and round-trips through the next read.
EXPECT_TRUE(tester.WriteAttribute(CPAttributes::ScanMaxTime::Id, static_cast<uint8_t>(45)).IsSuccess());
EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::ScanMaxTime::Id));
uint8_t scanMaxTime = 0;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::ScanMaxTime::Id, scanMaxTime), CHIP_NO_ERROR);
EXPECT_EQ(scanMaxTime, 45u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § Attributes: CacheTimeout (BGS-only) fallback = 120, access = "RW VO". Storage and
// change reporting are owned by the cluster: a write that changes the value SHALL emit a
// change report and be visible to the next read, and a write of the unchanged value SHALL
// NOT report.
TEST_F(TestCommissioningProxyCluster, TestCacheTimeoutAttribute_DefaultWritableAndChangeReporting)
{
BitMask<Feature> bgs(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
// Start with the tester's context so change notifications land in the dirty
// list the tester observes.
ClusterTester tester(cluster);
EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
// Default per spec fallback column.
uint16_t cacheTimeout = 0;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::CacheTimeout::Id, cacheTimeout), CHIP_NO_ERROR);
EXPECT_EQ(cacheTimeout, 120u);
// Writing the current value is a no-op: success, no change report.
EXPECT_TRUE(tester.WriteAttribute(CPAttributes::CacheTimeout::Id, static_cast<uint16_t>(120)).IsSuccess());
EXPECT_FALSE(tester.IsAttributeDirty(CPAttributes::CacheTimeout::Id));
// Writing a new value reports the change and round-trips through the next read.
EXPECT_TRUE(tester.WriteAttribute(CPAttributes::CacheTimeout::Id, static_cast<uint16_t>(60)).IsSuccess());
EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::CacheTimeout::Id));
ASSERT_EQ(tester.ReadAttribute(CPAttributes::CacheTimeout::Id, cacheTimeout), CHIP_NO_ERROR);
EXPECT_EQ(cacheTimeout, 60u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § Attributes: NumCachedResults (BGS-only) fallback = 0.
TEST_F(TestCommissioningProxyCluster, TestNumCachedResultsAttribute_DefaultZero)
{
TestServerClusterContext context;
BitMask<Feature> bgs(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint8_t numCachedResults = 99; // pre-set to non-zero so we know read overwrites
ASSERT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, numCachedResults), CHIP_NO_ERROR);
EXPECT_EQ(numCachedResults, 0u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § Attributes: MaxCachedResults (BGS-only) min 1. Reads from config.
TEST_F(TestCommissioningProxyCluster, TestMaxCachedResultsAttribute_ReadsFromConfig)
{
TestServerClusterContext context;
BitMask<Feature> bgs(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint8_t maxCachedResults = 0;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::MaxCachedResults::Id, maxCachedResults), CHIP_NO_ERROR);
EXPECT_EQ(maxCachedResults, cluster.GetMaxCachedResults());
EXPECT_GE(maxCachedResults, 1u); // spec: min 1
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § Attributes: WiFiBand (WI-only) reflects the configured supported bands.
TEST_F(TestCommissioningProxyCluster, TestWiFiBandAttribute_ReadsFromConfig)
{
TestServerClusterContext context;
chip::BitMask<WiFiBandBitmap> bands;
bands.Set(WiFiBandBitmap::k2g4);
bands.Set(WiFiBandBitmap::k5g);
BitMask<Feature> wi(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(wi), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(bands);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
chip::BitMask<WiFiBandBitmap> readBands;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::WiFiBand::Id, readBands), CHIP_NO_ERROR);
EXPECT_TRUE(readBands.Has(WiFiBandBitmap::k2g4));
EXPECT_TRUE(readBands.Has(WiFiBandBitmap::k5g));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// ProxyConnectRequest Command Tests
// =============================================================================
// Helper: build a minimal valid ProxyConnectRequest for a single transport.
static Commands::ProxyConnectRequest::Type MakeConnectRequest(CapabilitiesBitmap transport, uint16_t timeout = 30)
{
Commands::ProxyConnectRequest::Type cmd;
cmd.address.SetNull();
cmd.transport = transport;
cmd.discriminator = 0;
cmd.vendorID = chip::VendorId::Common;
cmd.productID = 0;
cmd.timeout = timeout;
return cmd;
}
// Establish a proxy session and return its sessionId (via the mock BLE transport).
static uint16_t OpenSession(ClusterTester & tester)
{
auto conn = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
EXPECT_TRUE(conn.IsSuccess());
EXPECT_TRUE(conn.response.has_value());
return conn.response.has_value() ? conn.response->sessionID : 0;
}
// Zero transport bits SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ZeroTransportBits)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(static_cast<CapabilitiesBitmap>(0));
// Spec: "Exactly one transport ... SHALL be selected"; zero bits is an invalid
// field, and the spec mandates INVALID_COMMAND for an invalid field.
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Multiple transport bits set SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_MultipleTransportBits)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kBle);
cmd.transport.Set(CapabilitiesBitmap::kWiFiPAF); // now two bits set
// Spec: "Exactly one transport ... SHALL be selected"; >1 bit is an invalid
// field → INVALID_COMMAND (test plan TC-2.6).
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// ProxyConnectRequest: Discriminator constraint is "0 to 4095". A value
// above 4095 is an invalid field and SHALL return InvalidCommand.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_DiscriminatorOutOfRange)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// Exactly one transport bit set so the single-transport check passes; the
// discriminator check runs before the transport-supported check, so this is
// independent of which transports are compiled in.
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
cmd.discriminator = chip::kMaxDiscriminatorValue + 1; // one past the spec max
auto result = tester.Invoke(cmd);
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A single reserved bit in transport (a bit outside the spec-defined kBle / kWiFiPAF /
// kNtl set) SHALL be rejected as a malformed field, not reported as a transport the
// proxy happens not to support: the ProxyConnectRequest Effect on Receipt reserves
// InvalidTransportType for a Transport "the proxy cannot support", and gives
// InvalidCommand for any other invalid field. This is also what the scan commands
// already return for a reserved bit.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ReservedTransportBitOnly)
{
TestServerClusterContext context;
BitMask<Feature> wi(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(wi), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// 0x01 is a reserved bit (kBle=0x02, kWiFiPAF=0x08, kNtl=0x10).
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(static_cast<CapabilitiesBitmap>(0x01));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// The WiFiPAF CapabilitiesBitmap conformance is O.a+, independent of the WI feature: WI
// gates only the WiFiBand field, which is absent here. So the same request SHALL be
// accepted whether or not WI is enabled, and SHALL return a sessionId.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiPAFIndependentOfWIFeature)
{
TestServerClusterContext context;
for (const BitMask<Feature> features : { BitMask<Feature>{}, BitMask<Feature>(Feature::kWiFiNetworkInterface) })
{
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF));
EXPECT_TRUE(result.IsSuccess());
ASSERT_TRUE(result.response.has_value());
if (result.response.has_value())
{
EXPECT_EQ(result.response->sessionID, 1u);
}
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
}
// WiFiBand present with a non-WiFiPAF transport SHALL return InvalidCommand
// (the WiFiBand field is only meaningful for the WiFiPAF transport per spec).
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiBandWithBleTransport)
{
TestServerClusterContext context;
// Enable WI so the cluster has wiFiBand plumbing wired; the rejection here
// is driven by the transport != kWiFiPAF check, not the WI feature bit.
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kBle);
cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// WiFiBand field present with WI feature enabled and band in supported set — SHALL succeed.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiBandWithWIFeature)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Reserved bits in wiFiBand SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ReservedWiFiBandBits)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
// bit 1 (0x02) is reserved.
cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02)));
// Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for an *unsupported*
// WiFiBand; it does not mandate a specific status for reserved WiFiBand bits. This
// asserts current behavior (INVALID_COMMAND for a malformed field).
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// wiFiBand not in the proxy's supported bands SHALL return InvalidTransportType.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_WiFiBandNotInSupportedBands)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
// Proxy only supports 2.4 GHz.
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
Commands::ProxyConnectRequest::Type cmd = MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF);
// Request 5 GHz — not in supported bands.
cmd.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Whenever the proxy advertises kBle, ProxyConnectRequest with kBle SHALL succeed and
// return a SessionID, and the cluster state SHALL move to kState_CPConnected.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_StateTransitionOnSuccess)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
EXPECT_TRUE(result.IsSuccess());
ASSERT_TRUE(result.response.has_value());
if (result.response.has_value())
{
EXPECT_EQ(result.response->sessionID, 1u);
}
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A failed ProxyConnectRequest SHALL NOT change the cluster state.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_StateUnchangedOnFailure)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// A reserved transport bit (0x01) is always rejected, whatever transports the build
// registers, so this is a build-independent connect failure that must not transition
// state.
EXPECT_FALSE(tester.Invoke(MakeConnectRequest(static_cast<CapabilitiesBitmap>(0x01))).IsSuccess());
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Per the ProxyConnectRequest Effect on Receipt: if the number of active sessions
// has reached the value of the MaxSessions attribute, a RESOURCE_EXHAUSTED status
// SHALL be returned. Enforced generically by the cluster from
// GetActiveSessionCount() vs the configured MaxSessions, so every transport inherits the
// behaviour without having to remember the check itself.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_ResourceExhaustedAtMaxSessions)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// Fill the session pool, then check the next connect is refused. A pending connect
// also counts toward the active-session total, so the last slot is taken that way.
for (uint8_t i = 0; i < CHIP_CONFIG_COMMISSIONING_PROXY_MAX_SESSIONS - 1; i++)
{
EXPECT_TRUE(tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle)).IsSuccess());
}
mockBle.SetConnectPending(true);
auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::ResourceExhausted));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Below MaxSessions: the cluster-level pre-check does not reject; the
// request is forwarded to the transport driver and (for the mock) succeeds.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_BelowMaxSessionsSucceeds)
{
SKIP_IF_MAX_SESSIONS_BELOW(2);
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
// First connect establishes one session, leaving at least one slot free.
EXPECT_TRUE(tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle)).IsSuccess());
// Second connect is still below MaxSessions, so the gate passes and it succeeds.
// (The MaxSessions == 1 exhaustion case is covered by _ResourceExhaustedAtMaxSessions.)
EXPECT_TRUE(tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle)).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Per the ProxyConnectRequest Effect on Receipt: if Timeout expires, the connection
// attempt is terminated and a TIMEOUT status SHALL be returned.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_TransportTimeout_Propagated)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
// The transport's connect times out; the cluster SHALL surface TIMEOUT.
mockBle.SetConnectStatus(Protocols::InteractionModel::Status::Timeout);
ClusterTester tester(cluster);
auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Timeout));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// ProxyDisconnectRequest Command Tests
// =============================================================================
// Disconnecting with the sessionId the connect returned SHALL succeed, and with no
// session left the cluster state SHALL revert to kState_CPDisconnected.
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_StateTransitionToDisconnected)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
uint16_t sid = OpenSession(tester);
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
Commands::ProxyDisconnectRequest::Type cmd;
cmd.sessionID.SetNonNull(sid);
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
// State SHALL be Disconnected after a successful disconnect.
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// With MaxSessions > 1, disconnecting one of several active sessions SHALL NOT
// transition the cluster to disconnected; only the final disconnect (no sessions
// remaining) SHALL do so.
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_MultiSessionStateTransition)
{
SKIP_IF_MAX_SESSIONS_BELOW(2);
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// Open two sessions and capture their (distinct) session IDs.
auto r1 = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
ASSERT_TRUE(r1.IsSuccess());
ASSERT_TRUE(r1.response.has_value());
auto r2 = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
ASSERT_TRUE(r2.IsSuccess());
ASSERT_TRUE(r2.response.has_value());
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
Commands::ProxyDisconnectRequest::Type cmd;
// First disconnect: one session remains, so state SHALL stay Connected.
if (r1.response.has_value())
{
cmd.sessionID.SetNonNull(r1.response->sessionID);
}
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
// Second disconnect: no sessions remain, so state SHALL revert to Disconnected.
if (r2.response.has_value())
{
cmd.sessionID.SetNonNull(r2.response->sessionID);
}
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPDisconnected);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// When the transport fails to disconnect, the command SHALL fail and the cluster
// state SHALL remain Connected (session not cleaned up).
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_TransportFailurePreservesState)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
auto connectResult = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
ASSERT_TRUE(connectResult.IsSuccess());
ASSERT_TRUE(connectResult.response.has_value());
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
// The transport rejects the disconnect.
mockBle.SetDisconnectStatus(Protocols::InteractionModel::Status::Failure);
Commands::ProxyDisconnectRequest::Type cmd;
if (connectResult.response.has_value())
{
cmd.sessionID.SetNonNull(connectResult.response->sessionID);
}
EXPECT_FALSE(tester.Invoke(cmd).IsSuccess());
// State SHALL remain Connected since the transport rejected the disconnect.
EXPECT_EQ(cluster.GetCPState(), CommissioningProxyCluster::kState_CPConnected);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A null SessionID with a pending connect SHALL return Success and cancel the connect.
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_CancelPending_Success)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// A connect is pending on the BLE transport, owned by fabric 2.
mockBle.SetPendingConnectFabric(2);
tester.SetFabricIndex(2);
Commands::ProxyDisconnectRequest::Type cmd;
cmd.sessionID.SetNull();
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
EXPECT_TRUE(mockBle.CancelCalled());
EXPECT_EQ(mockBle.LastCancelFabric(), 2);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A null SessionID with no ProxyConnectRequest in flight (e.g. already
// connected, or never started) has no pending connect to cancel.
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_CancelPending_AlreadyConnected)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// CancelPendingConnect inherits InvalidInState from the transport driver (no pending connect).
Commands::ProxyDisconnectRequest::Type cmd;
cmd.sessionID.SetNull();
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidInState));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A null SessionID from the wrong fabric SHALL return NotFound (fabric isolation).
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_CancelPending_WrongFabric)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// A connect is pending on the BLE transport, owned by fabric 1.
mockBle.SetPendingConnectFabric(1);
Commands::ProxyDisconnectRequest::Type cmd;
cmd.sessionID.SetNull();
// Fabric 2 tries to cancel fabric 1's pending connect — SHALL be rejected.
tester.SetFabricIndex(2);
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
// Fabric 1 (the owner) cancels its own pending connect — SHALL succeed.
tester.SetFabricIndex(1);
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Per the ProxyDisconnectRequest Effect on Receipt: if no transport connection
// with the specified SessionId exists (or the connection's fabric does not match
// the sending fabric), the command SHALL be rejected with NOT_FOUND. The cluster
// returns NotFound; cluster must propagate.
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_UnknownSession_NotFound)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
Commands::ProxyDisconnectRequest::Type cmd;
cmd.sessionID.SetNonNull(1234); // no such session registered
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// ProxyScanRequest Command Tests
// =============================================================================
// Zero transport bits SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ZeroTransport)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
Commands::ProxyScanRequest::Type command;
command.transport = static_cast<CapabilitiesBitmap>(0);
// Impl-defined: the ProxyScanRequest spec defines only a BUSY status; it mandates
// no specific status for a zero/malformed transport. This asserts current behavior.
auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Reserved bits in transport SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ReservedTransportBits)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// kWiFiPAF(0x08) | reserved(0x01) = 0x09 contains a reserved bit.
Commands::ProxyScanRequest::Type command;
command.transport = static_cast<CapabilitiesBitmap>(0x09);
// Impl-defined: the ProxyScanRequest spec defines only a BUSY status; it mandates
// no specific status for reserved transport bits. This asserts current behavior.
auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Whenever the proxy advertises kBle in the Transport attribute,
// ProxyScanRequest with kBle
// SHALL succeed regardless of which Feature bits the cluster was constructed
// with. Skips when the build does not include BLE.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_BleNoFeaturesSucceeds)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kBle;
EXPECT_TRUE(tester.Invoke(Commands::ProxyScanRequest::Id, command).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// ProxyScanRequest with both kBle and kWiFiPAF in a single transport mask
// (with the WI feature enabled) SHALL succeed. Skips when either transport
// is missing from the build.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_BleAndWiFiPAFTogether)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
Commands::ProxyScanRequest::Type command;
command.transport.Set(CapabilitiesBitmap::kBle);
command.transport.Set(CapabilitiesBitmap::kWiFiPAF);
// Each mock transport contributes 2 results; the aggregator SHALL combine both
// sub-scans into a single ProxyScanResponse and report the total count.
auto result = tester.Invoke(command);
ASSERT_TRUE(result.IsSuccess());
ASSERT_TRUE(result.response.has_value());
if (result.response.has_value())
{
EXPECT_EQ(result.response->numberOfResults, 4u);
size_t listCount = 0;
auto iter = result.response->proxyScanResult.begin();
while (iter.Next())
{
++listCount;
}
EXPECT_EQ(iter.GetStatus(), CHIP_NO_ERROR);
EXPECT_EQ(listCount, 4u);
}
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// The cluster registers each sub-scan only after starting it, so a transport reporting
// synchronously from inside its own Scan() satisfies the expected count while later
// transports have yet to be counted. Emitting there would answer the commissioner with a
// ProxyScanResponse missing every transport the cluster had not reached yet.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_SyncContributorWaitsForRemainingSubScans)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
// BLE is scanned first and reports later, as a real driver does from its own scan
// timer; PAF then reports synchronously from inside Scan().
mockBle.SetAutoContribute(false);
mockPaf.SetAutoContribute(true);
Commands::ProxyScanRequest::Type command;
command.transport.Set(CapabilitiesBitmap::kBle);
command.transport.Set(CapabilitiesBitmap::kWiFiPAF);
[[maybe_unused]] auto pending = tester.Invoke(command);
// PAF's synchronous report must not have closed the aggregation on its own.
EXPECT_FALSE(tester.GetCommandHandler().HasResponse());
// BLE reports; with every sub-scan in, the response carries both transports' results.
mockBle.ContributeScanResults();
ASSERT_TRUE(tester.GetCommandHandler().HasResponse());
Commands::ProxyScanResponse::DecodableType response;
ASSERT_EQ(tester.GetCommandHandler().DecodeResponse(response), CHIP_NO_ERROR);
EXPECT_EQ(response.numberOfResults, 4u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// The kWiFiPAF transport bit is O.a+, independent of WI: WI gates only the wiFiBands
// field, which is absent here. So the same scan SHALL be accepted either way.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WiFiPAFIndependentOfWIFeature)
{
TestServerClusterContext context;
for (const BitMask<Feature> features : { BitMask<Feature>{}, BitMask<Feature>(Feature::kWiFiNetworkInterface) })
{
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kWiFiPAF;
// No wiFiBands — skips the band validation entirely.
EXPECT_TRUE(tester.Invoke(Commands::ProxyScanRequest::Id, command).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
}
// WiFiBands field present without the WI feature is rejected. The kWiFiPAF transport
// itself is supported without WI; the rejection is driven solely by the
// wiFiBands-requires-WI guard. Impl-defined status: the spec does not mandate a
// specific status for a WI-conformance field sent without WI; this asserts current
// behavior (INVALID_COMMAND).
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WiFiBandWithoutWIFeature)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kWiFiPAF;
command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Reserved bits in wiFiBands SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ReservedWiFiBandBits)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// k2g4=0x01, k5g=0x04 are valid; bit 1 (0x02) is reserved.
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kWiFiPAF;
command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02)));
// Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for unsupported
// bands, not for reserved WiFiBand bits. This asserts current behavior.
auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// wiFiBands containing bits not in the proxy's supported bands SHALL return
// InvalidTransportType.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WiFiBandNotInSupportedBands)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
// Proxy only supports 2.4 GHz.
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// Request 5 GHz — not in supported bands.
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kWiFiPAF;
command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g));
auto result = tester.Invoke(Commands::ProxyScanRequest::Id, command);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// kWiFiPAF with WI feature and a wiFiBands value within supported bands — SHALL succeed.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ValidWiFiBandInSupportedBands)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
// Proxy supports both 2.4 GHz and 5 GHz.
chip::BitMask<WiFiBandBitmap> bothBands;
bothBands.Set(WiFiBandBitmap::k2g4);
bothBands.Set(WiFiBandBitmap::k5g);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(bothBands);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kWiFiPAF;
command.wiFiBands.SetValue(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_TRUE(tester.Invoke(Commands::ProxyScanRequest::Id, command).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// ProxyMessageRequest Command Tests
// =============================================================================
// ProxyMessageRequest with a non-null message SHALL succeed and return a
// ProxyMessageResponse carrying the same sessionId.
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_WithMessage)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
static const uint8_t kMsg[] = { 0x01, 0x02, 0x03, 0x04 };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
// The mock transport delivers an immediate (null) commissionee reply.
auto result = tester.Invoke(cmd);
EXPECT_TRUE(result.IsSuccess());
ASSERT_TRUE(result.response.has_value());
if (result.response.has_value())
{
EXPECT_EQ(result.response->sessionID, sid);
}
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A null ProxyMessageRequest.message SHALL succeed and
// return a ProxyMessageResponse with a null message (no data from commissionee).
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_NullMessage_Poll)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNull();
auto result = tester.Invoke(cmd);
EXPECT_TRUE(result.IsSuccess());
ASSERT_TRUE(result.response.has_value());
if (result.response.has_value())
{
EXPECT_EQ(result.response->sessionID, sid);
// Null response message signals no pending data from commissionee.
EXPECT_TRUE(result.response->message.IsNull());
}
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Per the ProxyMessageRequest Effect on Receipt: if no transport connection with
// the specified SessionID exists (or the fabric does not match), the command SHALL
// be rejected with NOT_FOUND. The cluster's session table enforces this.
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_UnknownSession_NotFound)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
static const uint8_t kMsg[] = { 0xFF };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = 9999; // no such session
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Per the ProxyMessageRequest Effect on Receipt: if another ProxyMessageRequest
// referencing the same SessionId is still outstanding, the command SHALL be
// rejected with BUSY. The cluster's session manager enforces this.
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_DuplicateRequest_Busy)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
// Leave the first request pending (no commissionee reply), so the second one
// for the same session hits the BUSY path.
mockBle.SetAutoRespond(false);
static const uint8_t kMsg[] = { 0xFF };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
[[maybe_unused]] auto pending = tester.Invoke(cmd); // first request: stays pending
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Busy));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// ProxyBackgroundScanStartRequest — parameter validation tests
// =============================================================================
// Helper: build a minimal valid ProxyBackgroundScanStartRequest.
static Commands::ProxyBackGroundScanStartRequest::Type
MakeBgScanStartRequest(CapabilitiesBitmap transport, uint16_t timeout = 30,
chip::Optional<chip::BitMask<WiFiBandBitmap>> wiFiBands = chip::NullOptional)
{
Commands::ProxyBackGroundScanStartRequest::Type cmd;
cmd.transport = transport;
cmd.timeout = timeout;
cmd.wiFiBands = wiFiBands;
return cmd;
}
// transport=0 SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ZeroTransport)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for an unsupported
// transport/band on BgScanStart; it does not mandate a status for a zero transport.
// This asserts current behavior.
auto result = tester.Invoke(MakeBgScanStartRequest(static_cast<CapabilitiesBitmap>(0)));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Reserved bits in transport SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ReservedTransportBits)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// CapabilitiesBitmap: kBle=0x02, kWiFiPAF=0x08; all other bits are reserved.
// 0x09 = kWiFiPAF(0x08) | reserved(0x01) → contains a reserved bit.
// Impl-defined: the spec mandates no specific status for reserved transport bits
// on BgScanStart. This asserts current behavior.
auto result = tester.Invoke(MakeBgScanStartRequest(static_cast<CapabilitiesBitmap>(0x09)));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// kBle is not gated by any Feature bit in the spec; whenever the proxy
// advertises kBle in the Transport attribute, ProxyBackGroundScanStartRequest
// with kBle SHALL succeed (only the BGS feature is required for the command
// to be accepted at all).
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_BleNoExtraFeaturesSucceeds)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
EXPECT_TRUE(tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kBle)).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// kWiFiPAF without WI feature SHALL be accepted: the WiFiPAF transport bit is
// O.a+, independent of WI. WI only gates the wiFiBands field (absent here).
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_WiFiPAFWithoutWIFeature)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
EXPECT_TRUE(tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF)).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// wiFiBands with reserved bits SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ReservedWiFiBandBits)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// k2g4=0x01, k5g=0x04 are valid; bit 1 (0x02) is reserved.
auto cmd = MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02))));
// Impl-defined: the spec mandates INVALID_TRANSPORT_TYPE only for unsupported
// bands, not reserved WiFiBand bits. This asserts current behavior.
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// kWiFiPAF with a wiFiBands field but no WI feature is rejected. The kWiFiPAF
// transport itself is supported without WI; the rejection is driven solely by the
// wiFiBands-requires-WI guard. Impl-defined status: the spec does not mandate a
// specific status for a WI-conformance field sent without WI; this asserts current
// behavior (INVALID_COMMAND).
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_WiFiPAFAndBandWithoutWIFeature)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
auto cmd = MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4)));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Valid kWiFiPAF with wiFiBands inside the supported set — SHALL succeed, for a single
// band and for a multi-bit mask ("Multiple frequency bands can be selected for the scan").
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_ValidWiFiPAFBands)
{
TestServerClusterContext context;
chip::BitMask<WiFiBandBitmap> bothBands;
bothBands.Set(WiFiBandBitmap::k2g4);
bothBands.Set(WiFiBandBitmap::k5g);
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(bothBands);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
EXPECT_TRUE(tester
.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4))))
.IsSuccess());
EXPECT_TRUE(tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30, chip::MakeOptional(bothBands))).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// kWiFiPAF with a WiFiBand bit not in GetSupportedWiFiBands() SHALL return INVALID_TRANSPORT_TYPE.
// Proxy only supports 2.4 GHz; requesting 5 GHz must be rejected.
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_UnsupportedWiFiBand)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
auto cmd = MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF, 30,
chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g)));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// ProxyBackgroundScanStopRequest — parameter validation tests
// =============================================================================
// Helper: build a ProxyBackgroundScanStopRequest.
static Commands::ProxyBackGroundScanStopRequest::Type
MakeBgScanStopRequest(CapabilitiesBitmap transport, chip::Optional<chip::BitMask<WiFiBandBitmap>> wiFiBands = chip::NullOptional)
{
Commands::ProxyBackGroundScanStopRequest::Type cmd;
cmd.transport = transport;
cmd.wiFiBands = wiFiBands;
return cmd;
}
// transport=0 and no wiFiBands SHALL be rejected (nothing to stop).
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ZeroTransportNoWiFiBands)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// Impl-defined: the BgScanStop spec defines only NOT_FOUND and SUCCESS; it mandates
// no specific status for an empty (transport=0, no bands) request. This asserts
// current behavior.
auto result = tester.Invoke(MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0)));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Reserved bits in transport SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ReservedTransportBits)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// CapabilitiesBitmap: kBle=0x02, kWiFiPAF=0x08; all other bits are reserved.
// 0x09 = kWiFiPAF(0x08) | reserved(0x01) → contains a reserved bit.
// Impl-defined: the BgScanStop spec mandates no specific status for reserved
// transport bits. This asserts current behavior.
auto result = tester.Invoke(MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0x09)));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// kBle is not gated by any Feature bit in the spec; whenever the proxy
// advertises kBle in the Transport attribute, ProxyBackGroundScanStopRequest
// with kBle passes the cluster-level transport validation and is forwarded to
// the transport driver. The mock returns Success, mirroring the WiFiPAF
// positive case below. (NotFound for an unrecognised fabric is a
// transport-level concern and is covered by the platform transport's own tests.)
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_BleValid)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
EXPECT_TRUE(tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kBle)).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// kWiFiPAF without WI feature SHALL be accepted: the WiFiPAF transport bit is
// O.a+, independent of WI. WI only gates the wiFiBands field (absent here).
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_WiFiPAFWithoutWIFeature)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
EXPECT_TRUE(tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kWiFiPAF)).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// wiFiBands with reserved bits SHALL be rejected.
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ReservedWiFiBandBits)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// Bit 1 (0x02) is reserved in WiFiBandBitmap.
auto cmd = MakeBgScanStopRequest(CapabilitiesBitmap::kWiFiPAF,
chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(static_cast<WiFiBandBitmap>(0x02))));
// Impl-defined: the BgScanStop spec mandates no specific status for reserved
// WiFiBand bits. This asserts current behavior.
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// wiFiBands without WI feature is rejected. Impl-defined status: the spec does not
// mandate a specific status for a WI-conformance field sent without WI; this asserts
// current behavior (INVALID_COMMAND).
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_WiFiBandWithoutWIFeature)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan); // no kWiFiNetworkInterface
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// A band-only stop (transport=0 + wiFiBands) without the WI feature SHALL be
// rejected with InvalidCommand by the wiFiBands-requires-WI guard.
auto cmd = MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0),
chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4)));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// transport=0 with valid wiFiBands and WI feature — SHALL succeed
// (a band-only stop is valid per the ProxyBackGroundScanStopRequest behaviour).
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_BandOnlyStop_Valid)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
auto cmd = MakeBgScanStopRequest(static_cast<CapabilitiesBitmap>(0),
chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4)));
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Valid kWiFiPAF stop with k5g wiFiBands — SHALL succeed.
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_ValidWiFiPAF_5gBand)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
auto cmd =
MakeBgScanStopRequest(CapabilitiesBitmap::kWiFiPAF, chip::MakeOptional(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k5g)));
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Per the ProxyBackGroundScanStopRequest Effect on Receipt: if the NodeId and
// FabricId of the client do not match those used in a previous
// ProxyBackGroundScanStartRequest, the proxy SHALL take no action and the command
// SHALL be rejected with NOT_FOUND. This is transport-side state; the cluster
// must propagate the transport's NotFound status.
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_TransportNotFound_Propagated)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
// No transport has a matching per-fabric background-scan record. The cluster
// fans the stop to every registered transport and reports NotFound only when
// none matched, so both mocks must report NotFound.
mockBle.SetBgScanStopStatus(Protocols::InteractionModel::Status::NotFound);
mockPaf.SetBgScanStopStatus(Protocols::InteractionModel::Status::NotFound);
ClusterTester tester(cluster);
auto result = tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kBle));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Per the ProxyBackGroundScanStopRequest Effect on Receipt: "If valid Transports and
// WiFiBands are received but were not originally requested, the command SHALL return a
// status of SUCCESS." The cluster fans the stop to every registered transport and
// reports SUCCESS when at least one matched, even if another reports NOT_FOUND.
TEST_F(TestCommissioningProxyCluster, TestBgScanStop_PartialMatch_Success)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
// BLE has no matching per-fabric record (NOT_FOUND); PAF reports SUCCESS.
mockBle.SetBgScanStopStatus(Protocols::InteractionModel::Status::NotFound);
mockPaf.SetBgScanStopStatus(Protocols::InteractionModel::Status::Success);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
EXPECT_TRUE(tester.Invoke(MakeBgScanStopRequest(CapabilitiesBitmap::kBle)).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// =============================================================================
// Additional coverage: read-only writes, unsupported transports, scan BUSY,
// ProxyMessage TIMEOUT, and CachedResults content.
// =============================================================================
// Build a minimal ScanResultStruct for injecting into the ScanCache.
static Structs::ScanResultStruct::Type MakeScanEntry(uint16_t discriminator, CapabilitiesBitmap transport)
{
Structs::ScanResultStruct::Type e;
e.transport = chip::BitMask<CapabilitiesBitmap>(transport);
e.discriminator = discriminator;
e.vendorID = static_cast<chip::VendorId>(0x1234);
e.productID = 0x0001;
e.address.SetNull();
e.extendedData.SetNull();
return e;
}
// As above, but on a band: only PAFTP results carry one.
static Structs::ScanResultStruct::Type MakePafScanEntry(uint16_t discriminator, WiFiBandBitmap band)
{
auto e = MakeScanEntry(discriminator, CapabilitiesBitmap::kWiFiPAF);
e.wiFiBand.SetValue(chip::BitMask<WiFiBandBitmap>(band));
return e;
}
// Spec § ProxyBackGroundScanStopRequest: clearing is scoped to "the transports and
// bands on which it has stopped scanning", so a band-scoped clear SHALL leave results
// from other bands cached. A result with no WiFiBand takes the spec's 2G4 fallback.
TEST_F(TestCommissioningProxyCluster, TestCachedResults_ClearIsBandSelective)
{
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
ClusterTester tester(cluster);
EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
auto readNum = [&]() {
uint8_t n = 0xFF;
EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
return n;
};
cluster.ScanCache().Report(MakePafScanEntry(1000, WiFiBandBitmap::k2g4));
cluster.ScanCache().Report(MakePafScanEntry(2000, WiFiBandBitmap::k5g));
cluster.ScanCache().Report(MakeScanEntry(3000, CapabilitiesBitmap::kWiFiPAF)); // no band -> 2G4
cluster.ScanCache().Report(MakeScanEntry(4000, CapabilitiesBitmap::kBle));
EXPECT_EQ(readNum(), 4u);
// Stopping 2G4 drops the 2G4 entry and the bandless one that falls back to it; the
// 5G entry and the BLE entry are untouched.
cluster.ScanCache().ClearTransport(chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kWiFiPAF),
chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(readNum(), 2u);
// A whole-transport clear (bands == 0) takes the remaining PAF entry, not the BLE one.
cluster.ScanCache().ClearTransport(chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kWiFiPAF));
EXPECT_EQ(readNum(), 1u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A valid transport bit with no registered driver SHALL be rejected with
// INVALID_TRANSPORT_TYPE (test plan TC-2.4 step 11). Register only BLE, request PAF.
// This is the distinction the two rejection statuses draw: a spec-defined transport with
// no driver is InvalidTransportType, whereas an undefined bit is a malformed field and
// gets InvalidCommand (see _ReservedTransportBitOnly). The certification tests rely on
// it to choose a transport bit for their negative steps.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_UnsupportedTransport_InvalidTransportType)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
cluster.RegisterTransport(mockBle); // only BLE
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
auto result = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kWiFiPAF));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// ProxyScanRequest for a valid-but-unregistered transport SHALL be rejected with
// INVALID_TRANSPORT_TYPE (test plan TC-2.2 step 9).
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_UnsupportedTransport_InvalidTransportType)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
cluster.RegisterTransport(mockBle); // only BLE
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kWiFiPAF;
auto result = tester.Invoke(command);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// ProxyBackGroundScanStartRequest for a valid-but-unregistered transport SHALL be
// rejected with INVALID_TRANSPORT_TYPE (spec + test plan TC-2.3 step 19).
TEST_F(TestCommissioningProxyCluster, TestBgScanStart_UnsupportedTransport_InvalidTransportType)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
cluster.RegisterTransport(mockBle); // only BLE
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
auto result = tester.Invoke(MakeBgScanStartRequest(CapabilitiesBitmap::kWiFiPAF));
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::InvalidTransportType));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Every command carrying a Transport field validates it separately, so the four checks can
// drift apart — ProxyConnectRequest once answered a reserved bit with InvalidTransportType
// while the three scan commands answered InvalidCommand, leaving the same malformed
// request with two different meanings depending on which command carried it. A reserved
// bit is a malformed field on all four; InvalidTransportType is reserved for a
// spec-defined transport the proxy has no driver for (see
// _UnsupportedTransport_InvalidTransportType). Assert the four agree.
TEST_F(TestCommissioningProxyCluster, TestReservedTransportBitRejectedAlikeByEveryCommand)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
// 0x01 is outside the spec-defined set (kBle=0x02, kWiFiPAF=0x08, kNtl=0x10). Sent
// alone so the single-transport rule ProxyConnectRequest applies cannot be what
// rejects it, and every command reaches its own reserved-bit check.
constexpr auto kReserved = static_cast<CapabilitiesBitmap>(0x01);
const auto expected = ClusterStatusCode(Protocols::InteractionModel::Status::InvalidCommand);
EXPECT_EQ(tester.Invoke(MakeConnectRequest(kReserved)).GetStatusCode(), expected) << "ProxyConnectRequest";
Commands::ProxyScanRequest::Type scan;
scan.transport = kReserved;
EXPECT_EQ(tester.Invoke(Commands::ProxyScanRequest::Id, scan).GetStatusCode(), expected) << "ProxyScanRequest";
EXPECT_EQ(tester.Invoke(MakeBgScanStartRequest(kReserved)).GetStatusCode(), expected) << "ProxyBackGroundScanStartRequest";
EXPECT_EQ(tester.Invoke(MakeBgScanStopRequest(kReserved)).GetStatusCode(), expected) << "ProxyBackGroundScanStopRequest";
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec: a ProxyScanRequest received while one is in progress MAY be answered with
// BUSY. This implementation chose the BUSY mechanism; verify it.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_ConcurrentBusy)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
// Leave the first scan in-flight (no synchronous contribution) so the aggregator
// stays busy for the second request.
mockBle.SetAutoContribute(false);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kBle;
[[maybe_unused]] auto first = tester.Invoke(command); // stays pending
auto second = tester.Invoke(command);
EXPECT_FALSE(second.IsSuccess());
EXPECT_EQ(second.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Busy));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// The scan watchdog is the only thing that can end an aggregation whose sub-scan never
// reports. If it cannot be armed the command SHALL be rejected rather than started,
// because an aggregation with no watchdog would never complete — and the rejection SHALL
// roll the aggregation back, or every later scan would be answered BUSY for the lifetime
// of the process.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WatchdogArmFailureRejectsAndRollsBack)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
mockTimer.FailNextStart();
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kBle;
auto result = tester.Invoke(command);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
// The aggregation was rolled back, so no watchdog is left armed...
EXPECT_EQ(mockTimer.ActiveCount(), 0u);
// ... and the next request arms its own watchdog and is accepted, not BUSY.
EXPECT_TRUE(tester.Invoke(command).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A driver whose transport connection drops mid-exchange reports it through the session
// manager rather than leaving the commissioner waiting for the response timer. The
// pending request SHALL be answered with the reported status and its timer released.
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_TransportFailureAnswersPending)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
mockBle.SetAutoRespond(false);
static const uint8_t kMsg[] = { 0xAB };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
[[maybe_unused]] auto pending = tester.Invoke(cmd);
EXPECT_FALSE(tester.GetCommandHandler().HasStatus());
EXPECT_EQ(mockTimer.ActiveCount(), 1u); // the response timer is armed
// The BTP/PAFTP link drops: the driver reports the failure for this session.
mockBle.FailPendingMessage(sid, Protocols::InteractionModel::Status::Failure);
ASSERT_TRUE(tester.GetCommandHandler().HasStatus());
EXPECT_EQ(tester.GetCommandHandler().GetLastStatus().status, ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
EXPECT_EQ(mockTimer.ActiveCount(), 0u); // ... and the response timer went with it
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § ProxyMessageResponse: "If the ResponseTimeout from the ProxyMessageRequest
// expires the TIMEOUT status SHALL be returned."
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_ResponseTimeout)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
// The commissionee never replies, so the request stays pending and only the session's
// response timer can resolve it.
mockBle.SetAutoRespond(false);
static const uint8_t kMsg[] = { 0xAB };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
// No answer yet: the cluster holds the handle open waiting for the commissionee.
// (Invoke() reports its own error for a command that answers asynchronously, so the
// handler is what says whether the commissioner has been answered.)
[[maybe_unused]] auto pending = tester.Invoke(cmd);
EXPECT_FALSE(tester.GetCommandHandler().HasStatus());
EXPECT_FALSE(tester.GetCommandHandler().HasResponse());
// Advancing past ResponseTimeout fires the timer, which answers with TIMEOUT.
mockTimer.AdvanceClock(System::Clock::Seconds16(5));
ASSERT_TRUE(tester.GetCommandHandler().HasStatus());
EXPECT_EQ(tester.GetCommandHandler().GetLastStatus().status, ClusterStatusCode(Protocols::InteractionModel::Status::Timeout));
// The expired request no longer holds the session, so the next one is accepted
// rather than rejected with BUSY.
mockBle.SetAutoRespond(true);
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A ProxyBackGroundScanStartRequest naming several transports must not report failure
// while leaving one of them scanning: the commissioner would believe nothing started.
// Either every requested transport starts, or the command rolls back and fails.
TEST_F(TestCommissioningProxyCluster, TestProxyBackgroundScanStart_PartialFailureRollsBack)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kBackgroundScan, Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
// BLE accepts the background scan; Wi-Fi PAF rejects it.
mockPaf.SetBgScanStartStatus(Protocols::InteractionModel::Status::Failure);
Commands::ProxyBackGroundScanStartRequest::Type cmd;
cmd.transport = chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kBle, CapabilitiesBitmap::kWiFiPAF);
cmd.timeout = 30;
EXPECT_FALSE(tester.Invoke(cmd).IsSuccess());
// BLE's scan must have been stopped again, so "failed" means nothing is running.
EXPECT_FALSE(mockBle.BgScanRunning());
EXPECT_EQ(mockBle.BgScanStopCount(), 1u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A sub-scan whose completion callback never fires must not block the aggregator
// forever: the watchdog ends the aggregation so later scans are still accepted.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_WatchdogEndsStalledAggregation)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
// The sub-scan starts but never reports, so only the watchdog can finish this.
mockBle.SetAutoContribute(false);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kBle;
[[maybe_unused]] auto stalled = tester.Invoke(command);
// A second request while the first is still aggregating is rejected.
EXPECT_EQ(tester.Invoke(command).GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Busy));
// ScanMaxTime defaults to 10s and the watchdog adds a 5s margin.
mockTimer.AdvanceClock(System::Clock::Seconds16(16));
// The aggregation has been closed out, so a new scan is accepted again.
mockBle.SetAutoContribute(true);
EXPECT_TRUE(tester.Invoke(command).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// The response timer is what eventually answers a ProxyMessageRequest the commissionee
// never replies to. If it cannot be armed the command SHALL be rejected, so the
// commissioner gets an answer instead of an exchange that is never resolved.
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_ResponseTimerArmFailureRejects)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
// The commissionee would not reply on its own, so only the timer could resolve this.
mockBle.SetAutoRespond(false);
static const uint8_t kMsg[] = { 0xAB };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
mockTimer.FailNextStart();
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A removed fabric SHALL leave nothing behind: its sessions are torn down through the
// owning transport, and every driver is told to drop its background scans. FabricIndex
// values are reused, so residue would be inherited by the next fabric to take the index.
TEST_F(TestCommissioningProxyCluster, TestOnFabricRemoved_DropsSessionsAndDriverState)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
tester.SetFabricIndex(1);
uint16_t sid = OpenSession(tester);
EXPECT_TRUE(cluster.Sessions().FindSession(sid).has_value());
// No FabricTable in unit tests, so drive the delegate callback directly.
cluster.OnFabricRemoved(chip::Server::GetInstance().GetFabricTable(), 1);
EXPECT_FALSE(cluster.Sessions().FindSession(sid).has_value());
EXPECT_TRUE(cluster.Sessions().IsEmpty());
EXPECT_EQ(mockBle.FabricRemovedCount(), 1u);
EXPECT_EQ(mockPaf.FabricRemovedCount(), 1u);
EXPECT_EQ(mockBle.LastRemovedFabric(), 1);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Another fabric's session SHALL survive.
TEST_F(TestCommissioningProxyCluster, TestOnFabricRemoved_LeavesOtherFabricsAlone)
{
SKIP_IF_MAX_SESSIONS_BELOW(2);
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
tester.SetFabricIndex(1);
uint16_t sid1 = OpenSession(tester);
tester.SetFabricIndex(2);
uint16_t sid2 = OpenSession(tester);
cluster.OnFabricRemoved(chip::Server::GetInstance().GetFabricTable(), 1);
EXPECT_FALSE(cluster.Sessions().FindSession(sid1).has_value());
EXPECT_TRUE(cluster.Sessions().FindSession(sid2).has_value());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// CachedResults / NumCachedResults reflect the ScanCache: null/0 when empty, unique
// per discriminator/VID/PID/transport (spec), and cleared by ClearTransport. Change
// reporting for both attributes is the cluster's responsibility.
TEST_F(TestCommissioningProxyCluster, TestCachedResults_ReportDedupAndClear)
{
BitMask<Feature> bgs(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
ClusterTester tester(cluster);
EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
auto readNum = [&]() {
uint8_t n = 0xFF;
EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
return n;
};
// Empty cache: NumCachedResults 0, CachedResults null.
EXPECT_EQ(readNum(), 0u);
{
Attributes::CachedResults::TypeInfo::DecodableType list;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::CachedResults::Id, list), CHIP_NO_ERROR);
EXPECT_TRUE(list.IsNull());
}
// Report device A → count 1; CachedResults and NumCachedResults marked dirty.
cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
EXPECT_EQ(readNum(), 1u);
EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::CachedResults::Id));
EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::NumCachedResults::Id));
// Report device B (different discriminator) → count 2.
cluster.ScanCache().Report(MakeScanEntry(2000, CapabilitiesBitmap::kBle));
EXPECT_EQ(readNum(), 2u);
// Re-report device A (same discriminator/VID/PID/transport) → dedup, count unchanged.
cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
EXPECT_EQ(readNum(), 2u);
// CachedResults is now a non-null list with 2 entries.
{
Attributes::CachedResults::TypeInfo::DecodableType list;
ASSERT_EQ(tester.ReadAttribute(CPAttributes::CachedResults::Id, list), CHIP_NO_ERROR);
ASSERT_FALSE(list.IsNull());
size_t count = 0;
auto it = list.Value().begin();
while (it.Next())
{
++count;
}
EXPECT_EQ(it.GetStatus(), CHIP_NO_ERROR);
EXPECT_EQ(count, 2u);
}
// Clearing the BLE transport removes all its entries → count 0.
cluster.ScanCache().ClearTransport(chip::BitMask<CapabilitiesBitmap>(CapabilitiesBitmap::kBle));
EXPECT_EQ(readNum(), 0u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § ProxyBackGroundScanStartRequest: "The proxy SHALL retain each result for
// CacheTimeout ... and SHALL remove the result upon expiry."
TEST_F(TestCommissioningProxyCluster, TestCachedResults_EntryExpiresAfterCacheTimeout)
{
BitMask<Feature> bgs(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
ClusterTester tester(cluster);
EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
auto readNum = [&]() {
uint8_t n = 0xFF;
EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
return n;
};
// CacheTimeout defaults to 120s.
cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
EXPECT_EQ(readNum(), 1u);
// Still inside the TTL: the sweep runs but keeps the entry.
mockTimer.AdvanceClock(System::Clock::Seconds16(119));
EXPECT_EQ(readNum(), 1u);
// Past the TTL: the entry is dropped and both attributes are reported.
tester.GetDirtyList().clear();
mockTimer.AdvanceClock(System::Clock::Seconds16(2));
EXPECT_EQ(readNum(), 0u);
EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::CachedResults::Id));
EXPECT_TRUE(tester.IsAttributeDirty(CPAttributes::NumCachedResults::Id));
// Nothing left to sweep, so the cache stops re-arming its timer.
EXPECT_EQ(mockTimer.ActiveCount(), 0u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § ProxyBackGroundScanStartRequest: "for each discovered device, the proxy SHALL
// reset the timer associated to this device on rediscovery of the same device".
TEST_F(TestCommissioningProxyCluster, TestCachedResults_RediscoveryResetsTtl)
{
BitMask<Feature> bgs(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
ClusterTester tester(cluster);
EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
auto readNum = [&]() {
uint8_t n = 0xFF;
EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
return n;
};
cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
// Rediscovered at t=100s, which pushes expiry out to t=220s.
mockTimer.AdvanceClock(System::Clock::Seconds16(100));
cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
// t=130s is past the ORIGINAL 120s expiry; the refreshed entry SHALL survive.
mockTimer.AdvanceClock(System::Clock::Seconds16(30));
EXPECT_EQ(readNum(), 1u);
// t=221s is past the refreshed expiry.
mockTimer.AdvanceClock(System::Clock::Seconds16(91));
EXPECT_EQ(readNum(), 0u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Entries with different TTLs expire independently, and the sweep keeps re-arming
// while any entry remains.
TEST_F(TestCommissioningProxyCluster, TestCachedResults_SweepExpiresOnlyDueEntries)
{
BitMask<Feature> bgs(Feature::kBackgroundScan);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(bgs), mockTimer);
RegisterMocks(cluster);
ClusterTester tester(cluster);
EXPECT_EQ(cluster.Startup(tester.GetServerClusterContext()), CHIP_NO_ERROR);
auto readNum = [&]() {
uint8_t n = 0xFF;
EXPECT_EQ(tester.ReadAttribute(CPAttributes::NumCachedResults::Id, n), CHIP_NO_ERROR);
return n;
};
cluster.ScanCache().Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle)); // expires t=120
mockTimer.AdvanceClock(System::Clock::Seconds16(60));
cluster.ScanCache().Report(MakeScanEntry(2000, CapabilitiesBitmap::kBle)); // expires t=180
EXPECT_EQ(readNum(), 2u);
// t=121: only the first entry is due; the sweep stays armed for the second.
mockTimer.AdvanceClock(System::Clock::Seconds16(61));
EXPECT_EQ(readNum(), 1u);
EXPECT_EQ(mockTimer.ActiveCount(), 1u);
mockTimer.AdvanceClock(System::Clock::Seconds16(60));
EXPECT_EQ(readNum(), 0u);
EXPECT_EQ(mockTimer.ActiveCount(), 0u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A cache destroyed while its TTL sweep is armed must cancel the timer on the way out:
// the TimerDelegate holds a pointer to the cache as its TimerContext, and would
// dereference freed memory when the sweep came due.
TEST_F(TestCommissioningProxyCluster, TestScanCache_DestructorCancelsSweepTimer)
{
class CacheObserver : public ScanCacheObserver
{
public:
void MarkCachedResultsDirty() override {}
uint16_t GetCacheTimeout() const override { return 60; }
uint8_t GetMaxCachedResults() const override { return 10; }
};
CacheObserver observer;
{
CommissioningProxyScanCache cache(observer, mockTimer);
cache.Report(MakeScanEntry(1000, CapabilitiesBitmap::kBle));
EXPECT_EQ(mockTimer.ActiveCount(), 1u); // the sweep is armed
}
// Shutdown() was never called, so only the destructor can have cancelled it.
EXPECT_EQ(mockTimer.ActiveCount(), 0u);
}
// =============================================================================
// Additional coverage: fabric isolation on established sessions, session
// lifecycle, ProxyMessage edge paths, scan sub-scan handling, GeneratedCommands.
// =============================================================================
// Spec: the proxy associates a session with the invoking fabric, and a disconnect
// whose fabric does not match SHALL be rejected with NOT_FOUND. Fabric 1 opens a
// session; fabric 2 cannot disconnect it, but fabric 1 can.
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_WrongFabricEstablishedSession_NotFound)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
tester.SetFabricIndex(1);
uint16_t sid = OpenSession(tester);
Commands::ProxyDisconnectRequest::Type cmd;
cmd.sessionID.SetNonNull(sid);
// Fabric 2 attempts to disconnect fabric 1's session → NOT_FOUND.
tester.SetFabricIndex(2);
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
// The owning fabric can still disconnect it (session survived the foreign attempt).
tester.SetFabricIndex(1);
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec: a ProxyMessageRequest whose fabric does not match the session's fabric SHALL
// be rejected with NOT_FOUND.
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_WrongFabricEstablishedSession_NotFound)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
tester.SetFabricIndex(1);
uint16_t sid = OpenSession(tester);
tester.SetFabricIndex(2);
static const uint8_t kMsg[] = { 0x01 };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
auto result = tester.Invoke(cmd);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec: "The SessionId allows multiple commissioning sessions to be run in parallel."
// With MaxSessions >= 2, two connects SHALL each get a distinct, non-zero SessionId.
TEST_F(TestCommissioningProxyCluster, TestProxyConnectRequest_MultipleSessionsHaveDistinctSessionIds)
{
SKIP_IF_MAX_SESSIONS_BELOW(2);
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
auto first = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
ASSERT_TRUE(first.IsSuccess());
ASSERT_TRUE(first.response.has_value());
auto second = tester.Invoke(MakeConnectRequest(CapabilitiesBitmap::kBle));
ASSERT_TRUE(second.IsSuccess());
ASSERT_TRUE(second.response.has_value());
if (first.response.has_value() && second.response.has_value())
{
EXPECT_NE(first.response->sessionID, 0u);
EXPECT_NE(second.response->sessionID, 0u);
EXPECT_NE(first.response->sessionID, second.response->sessionID);
}
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// A ProxyDisconnectRequest SHALL remove the session: a subsequent ProxyMessageRequest
// referencing the same SessionId SHALL be rejected with NOT_FOUND.
TEST_F(TestCommissioningProxyCluster, TestProxyDisconnectRequest_RemovesSession)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
uint16_t sid = OpenSession(tester);
Commands::ProxyDisconnectRequest::Type dc;
dc.sessionID.SetNonNull(sid);
EXPECT_TRUE(tester.Invoke(dc).IsSuccess());
static const uint8_t kMsg[] = { 0x01 };
Commands::ProxyMessageRequest::Type msg;
msg.sessionID = sid;
msg.responseTimeout = 5;
msg.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
auto result = tester.Invoke(msg);
EXPECT_FALSE(result.IsSuccess());
EXPECT_EQ(result.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::NotFound));
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// Spec § ProxyMessageRequest ResponseTimeout: "A value of zero indicates no response
// is expected and the proxy should send ProxyMessageResponse immediately indicating
// success." The response message SHALL be null.
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_ResponseTimeoutZero_ImmediateSuccess)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
static const uint8_t kMsg[] = { 0x01, 0x02 };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 0;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
auto result = tester.Invoke(cmd);
ASSERT_TRUE(result.IsSuccess());
ASSERT_TRUE(result.response.has_value());
if (result.response.has_value())
{
EXPECT_EQ(result.response->sessionID, sid);
EXPECT_TRUE(result.response->message.IsNull());
}
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// If the transport SendMessage fails, the command SHALL fail and the pending state
// SHALL be rolled back (the session is not left BUSY for the next request).
TEST_F(TestCommissioningProxyCluster, TestProxyMessageRequest_SendMessageFailure_RollsBackPending)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
uint16_t sid = OpenSession(tester);
static const uint8_t kMsg[] = { 0xAA };
Commands::ProxyMessageRequest::Type cmd;
cmd.sessionID = sid;
cmd.responseTimeout = 5;
cmd.message.SetNonNull(chip::ByteSpan(kMsg, sizeof(kMsg)));
// First request: the transport send fails → FAILURE.
mockBle.SetSendMessageError(CHIP_ERROR_INTERNAL);
auto first = tester.Invoke(cmd);
EXPECT_FALSE(first.IsSuccess());
EXPECT_EQ(first.GetStatusCode(), ClusterStatusCode(Protocols::InteractionModel::Status::Failure));
// The pending state was rolled back: a follow-up request is not rejected as BUSY.
mockBle.SetSendMessageError(CHIP_NO_ERROR);
EXPECT_TRUE(tester.Invoke(cmd).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// If no requested sub-scan can be started, the ProxyScanRequest SHALL fail rather
// than hang waiting for a contribution that never comes.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_AllSubScansFailToStart_Error)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
mockBle.SetScanStatus(Protocols::InteractionModel::Status::Failure);
Commands::ProxyScanRequest::Type command;
command.transport = CapabilitiesBitmap::kBle;
EXPECT_FALSE(tester.Invoke(command).IsSuccess());
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// If some (but not all) requested sub-scans start, the combined ProxyScanResponse
// SHALL carry the results of the transports that did start.
TEST_F(TestCommissioningProxyCluster, TestProxyScanRequest_PartialStart_ReturnsStartedResults)
{
TestServerClusterContext context;
BitMask<Feature> features(Feature::kWiFiNetworkInterface);
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(features), mockTimer);
RegisterMocks(cluster);
cluster.SetSupportedWiFiBands(chip::BitMask<WiFiBandBitmap>(WiFiBandBitmap::k2g4));
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ClusterTester tester(cluster);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kBle);
SKIP_IF_TRANSPORT_UNSUPPORTED(tester, CapabilitiesBitmap::kWiFiPAF);
// PAF fails to start; BLE succeeds and contributes 2 results.
mockPaf.SetScanStatus(Protocols::InteractionModel::Status::Failure);
Commands::ProxyScanRequest::Type command;
command.transport.Set(CapabilitiesBitmap::kBle);
command.transport.Set(CapabilitiesBitmap::kWiFiPAF);
auto result = tester.Invoke(command);
ASSERT_TRUE(result.IsSuccess());
ASSERT_TRUE(result.response.has_value());
if (result.response.has_value())
{
EXPECT_EQ(result.response->numberOfResults, 2u);
}
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
// GeneratedCommandList SHALL advertise the three response commands the server
// generates (regression guard for the GeneratedCommands override).
TEST_F(TestCommissioningProxyCluster, TestGeneratedCommandsAdvertisesResponses)
{
TestServerClusterContext context;
CommissioningProxyCluster cluster(kTestEndpointId, CommissioningProxyCluster::Config(BitMask<Feature>{}), mockTimer);
RegisterMocks(cluster);
EXPECT_EQ(cluster.Startup(context.Get()), CHIP_NO_ERROR);
ReadOnlyBufferBuilder<CommandId> generated;
EXPECT_EQ(cluster.GeneratedCommands(ConcreteClusterPath(kTestEndpointId, CommissioningProxy::Id), generated), CHIP_NO_ERROR);
auto buffer = generated.TakeBuffer();
bool hasConnect = false, hasScan = false, hasMessage = false;
for (const CommandId id : buffer)
{
hasConnect |= (id == Commands::ProxyConnectResponse::Id);
hasScan |= (id == Commands::ProxyScanResponse::Id);
hasMessage |= (id == Commands::ProxyMessageResponse::Id);
}
EXPECT_TRUE(hasConnect);
EXPECT_TRUE(hasScan);
EXPECT_TRUE(hasMessage);
EXPECT_EQ(buffer.size(), 3u);
cluster.Shutdown(ClusterShutdownType::kClusterShutdown);
}
} // namespace