blob: 2b2f9fda9f589fbb71e992a6f6f2af2afbb8963e [file] [log] [blame]
/*
* Copyright (c) 2025 Project CHIP Authors
*
* 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.
*/
#pragma once
#include <app/AttributeValueDecoder.h>
#include <app/AttributeValueEncoder.h>
#include <app/CommandHandler.h>
#include <app/ConcreteAttributePath.h>
#include <app/ConcreteClusterPath.h>
#include <app/ConcreteCommandPath.h>
#include <app/ConcreteEventPath.h>
#include <app/data-model-provider/ActionReturnStatus.h>
#include <app/data-model-provider/MetadataTypes.h>
#include <app/data-model-provider/StringBuilderAdapters.h>
#include <app/data-model-provider/tests/ReadTesting.h>
#include <app/data-model-provider/tests/WriteTesting.h>
#include <app/data-model/List.h>
#include <app/data-model/NullObject.h>
#include <app/server-cluster/ServerClusterInterface.h>
#include <app/server-cluster/testing/FabricTestFixture.h>
#include <app/server-cluster/testing/MockCommandHandler.h>
#include <app/server-cluster/testing/TestServerClusterContext.h>
#include <clusters/shared/Attributes.h>
#include <credentials/FabricTable.h>
#include <credentials/PersistentStorageOpCertStore.h>
#include <crypto/CHIPCryptoPAL.h>
#include <lib/core/CHIPError.h>
#include <lib/core/CHIPPersistentStorageDelegate.h>
#include <lib/core/DataModelTypes.h>
#include <lib/core/StringBuilderAdapters.h>
#include <lib/core/TLVReader.h>
#include <lib/support/ReadOnlyBuffer.h>
#include <lib/support/Span.h>
#include <protocols/interaction_model/StatusCode.h>
#include <algorithm>
#include <memory>
#include <optional>
#include <type_traits>
#include <vector>
namespace chip {
namespace Testing {
// Helper class for testing clusters.
//
// This class ensures that data read by attribute is referencing valid memory for all
// read requests until the ClusterTester object goes out of scope. (for the case where the underlying read references a list or
// string that points to TLV data).
//
// Read/Write of all attribute types should work, but make sure to use ::Type for encoding
// and ::DecodableType for decoding structure types.
//
// Example of usage:
//
// ExampleCluster cluster(someEndpointId);
//
// // Possibly steps to setup the cluster
//
// ClusterTester tester(cluster);
// app::Clusters::ExampleCluster::Attributes::FeatureMap::TypeInfo::DecodableType features;
// ASSERT_EQ(tester.ReadAttribute(FeatureMap::Id, features), CHIP_NO_ERROR);
//
// app::Clusters::ExampleCluster::Attributes::ExampleListAttribute::TypeInfo::DecodableType list;
// ASSERT_EQ(tester.ReadAttribute(LabelList::Id, list), CHIP_NO_ERROR);
// auto it = list.begin();
// while (it.Next())
// {
// ASSERT_GT(it.GetValue().label.size(), 0u);
// }
//
class ClusterTester
{
public:
ClusterTester(app::ServerClusterInterface & cluster) : mCluster(cluster), mFabricTestFixture(nullptr) {}
// Constructor with FabricHelper
ClusterTester(app::ServerClusterInterface & cluster, FabricTestFixture * fabricHelper) :
mCluster(cluster), mFabricTestFixture(fabricHelper)
{}
TestServerClusterContext & GetTestContext() { return mTestServerClusterContext; }
app::ServerClusterContext & GetServerClusterContext() { return mTestServerClusterContext.Get(); }
// Read attribute into `out` parameter.
// The `out` parameter must be of the correct type for the attribute being read.
// Use `app::Clusters::<ClusterName>::Attributes::<AttributeName>::TypeInfo::DecodableType` for the `out` parameter to be spec
// compliant (see the comment of the class for usage example).
// Will construct the attribute path using the first path returned by `GetPaths()` on the cluster.
// @returns `CHIP_ERROR_INCORRECT_STATE` if `GetPaths()` doesn't return a list with one path.
// @returns `CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute)` if the attribute is not present in AttributeList.
template <typename T>
app::DataModel::ActionReturnStatus ReadAttribute(AttributeId attr_id, T & out)
{
VerifyOrReturnError(VerifyClusterPathsValid(), CHIP_ERROR_INCORRECT_STATE);
// Verify that the attribute is present in AttributeList before attempting to read it.
// This ensures tests match real-world behavior where the Interaction Model checks AttributeList first.
VerifyOrReturnError(IsAttributeInAttributeList(attr_id), Protocols::InteractionModel::Status::UnsupportedAttribute);
auto path = mCluster.GetPaths()[0];
// Store the read operation in a vector<std::unique_ptr<...>> to ensure its lifetime
// using std::unique_ptr because ReadOperation is non-copyable and non-movable
// vector reallocation is not an issue since we store unique_ptrs
std::unique_ptr<chip::Testing::ReadOperation> readOperation =
std::make_unique<chip::Testing::ReadOperation>(path.mEndpointId, path.mClusterId, attr_id);
mReadOperations.push_back(std::move(readOperation));
chip::Testing::ReadOperation & readOperationRef = *mReadOperations.back().get();
const Access::SubjectDescriptor subjectDescriptor = mHandler.GetSubjectDescriptor();
readOperationRef.SetSubjectDescriptor(subjectDescriptor);
std::unique_ptr<app::AttributeValueEncoder> encoder = readOperationRef.StartEncoding();
app::DataModel::ActionReturnStatus status = mCluster.ReadAttribute(readOperationRef.GetRequest(), *encoder);
VerifyOrReturnError(status.IsSuccess(), status);
ReturnErrorOnFailure(readOperationRef.FinishEncoding());
std::vector<chip::Testing::DecodedAttributeData> attributeData;
ReturnErrorOnFailure(readOperationRef.GetEncodedIBs().Decode(attributeData));
VerifyOrReturnError(attributeData.size() == 1u, CHIP_ERROR_INCORRECT_STATE);
return app::DataModel::Decode(attributeData[0].dataReader, out);
}
// Write attribute from `value` parameter.
// The `value` parameter must be of the correct type for the attribute being written.
// Use `app::Clusters::<ClusterName>::Attributes::<AttributeName>::TypeInfo::Type` for the `value` parameter to be spec
// compliant (see the comment of the class for usage example).
// Will construct the attribute path using the first path returned by `GetPaths()` on the cluster.
// @returns `CHIP_ERROR_INCORRECT_STATE` if `GetPaths()` doesn't return a list with one path.
// @returns `CHIP_IM_GLOBAL_STATUS(UnsupportedAttribute)` if the attribute is not present in AttributeList.
template <typename T>
app::DataModel::ActionReturnStatus WriteAttribute(AttributeId attr, const T & value)
{
const auto & paths = mCluster.GetPaths();
VerifyOrReturnError(paths.size() == 1u, CHIP_ERROR_INCORRECT_STATE);
// Verify that the attribute is present in AttributeList before attempting to write it.
// This ensures tests match real-world behavior where the Interaction Model checks AttributeList first.
VerifyOrReturnError(IsAttributeInAttributeList(attr), Protocols::InteractionModel::Status::UnsupportedAttribute);
app::ConcreteAttributePath path(paths[0].mEndpointId, paths[0].mClusterId, attr);
chip::Testing::WriteOperation writeOp(path);
// Create a stable object on the stack
const Access::SubjectDescriptor subjectDescriptor = mHandler.GetSubjectDescriptor();
writeOp.SetSubjectDescriptor(subjectDescriptor);
uint8_t buffer[1024];
TLV::TLVWriter writer;
writer.Init(buffer);
// - DataModel::Encode(integral, enum, etc.) for simple types.
// - DataModel::Encode(List<X>) for lists (which iterates and calls Encode on elements).
// - DataModel::Encode(Struct) for non-fabric-scoped structs.
// - Note: For attribute writes, DataModel::EncodeForWrite is usually preferred for fabric-scoped types,
// but the generic DataModel::Encode often works as a top-level function.
// If you use EncodeForWrite, you ensure fabric-scoped list items are handled correctly:
if constexpr (app::DataModel::IsFabricScoped<T>::value)
{
ReturnErrorOnFailure(chip::app::DataModel::EncodeForWrite(writer, TLV::AnonymousTag(), value));
}
else
{
ReturnErrorOnFailure(chip::app::DataModel::Encode(writer, TLV::AnonymousTag(), value));
}
TLV::TLVReader reader;
reader.Init(buffer, writer.GetLengthWritten());
ReturnErrorOnFailure(reader.Next());
app::AttributeValueDecoder decoder(reader, *writeOp.GetRequest().subjectDescriptor);
return mCluster.WriteAttribute(writeOp.GetRequest(), decoder);
}
// Result structure for Invoke operations, containing both status and decoded response.
template <typename ResponseType>
struct InvokeResult
{
std::optional<app::DataModel::ActionReturnStatus> status;
std::optional<ResponseType> response;
// Returns true if the command was successful and response is available
bool IsSuccess() const
{
if constexpr (std::is_same_v<ResponseType, app::DataModel::NullObjectType>)
return status.has_value() && status->IsSuccess();
else
return status.has_value() && status->IsSuccess() && response.has_value();
}
// Returns the ClusterStatusCode if available, otherwise returns std::nullopt.
// This allows tests to check the status code with a single EXPECT_EQ()
// (i.e. without having to ASSERT_TRUE(status.has_value()) first).
std::optional<Protocols::InteractionModel::ClusterStatusCode> GetStatusCode() const
{
VerifyOrReturnValue(status.has_value(), std::nullopt);
return status->GetStatusCode();
}
};
// Invoke a command and return the decoded result.
// The `request` parameter must be of the correct type for the command being invoked.
// Use `app::Clusters::<ClusterName>::Commands::<CommandName>::Type` for the `request` parameter to be spec compliant
// Will construct the command path using the first path returned by `GetPaths()` on the cluster.
// @returns `CHIP_ERROR_INCORRECT_STATE` if `GetPaths()` doesn't return a list with one path.
template <typename RequestType, typename ResponseType = typename RequestType::ResponseType>
[[nodiscard]] InvokeResult<ResponseType> Invoke(chip::CommandId commandId, const RequestType & request)
{
InvokeResult<ResponseType> result;
const auto & paths = mCluster.GetPaths();
VerifyOrReturnValue(paths.size() == 1u, result);
mHandler.ClearResponses();
mHandler.ClearStatuses();
// Verify that the command is present in AcceptedCommands before attempting to invoke it.
// This ensures tests match real-world behavior where the Interaction Model checks AcceptedCommands first.
if (!IsCommandAnAcceptedCommand(commandId))
{
result.status = Protocols::InteractionModel::Status::UnsupportedCommand;
return result;
}
const Access::SubjectDescriptor subjectDescriptor = mHandler.GetSubjectDescriptor();
const app::DataModel::InvokeRequest invokeRequest = [&]() {
app::DataModel::InvokeRequest req;
req.path = { paths[0].mEndpointId, paths[0].mClusterId, commandId };
req.subjectDescriptor = &subjectDescriptor;
return req;
}();
TLV::TLVWriter writer;
writer.Init(mTlvBuffer);
TLV::TLVReader reader;
VerifyOrReturnValue(request.Encode(writer, TLV::AnonymousTag()) == CHIP_NO_ERROR, result);
VerifyOrReturnValue(writer.Finalize() == CHIP_NO_ERROR, result);
reader.Init(mTlvBuffer, writer.GetLengthWritten());
VerifyOrReturnValue(reader.Next(TLV::kTLVType_Structure, TLV::AnonymousTag()) == CHIP_NO_ERROR, result);
result.status = mCluster.InvokeCommand(invokeRequest, reader, &mHandler);
// If InvokeCommand returned nullopt, it means the command implementation handled the response.
// We need to check the mock handler for a data response or a status response.
if (!result.status.has_value())
{
if (mHandler.HasResponse())
{
// A data response was added, so the command is successful.
result.status = app::DataModel::ActionReturnStatus(CHIP_NO_ERROR);
}
else if (mHandler.HasStatus())
{
// A status response was added. Use the last one.
result.status = app::DataModel::ActionReturnStatus(mHandler.GetLastStatus().status);
}
else
{
// Neither response nor status was provided; this is unexpected.
// This would happen either in error (as mentioned here) or if the command is supposed
// to be handled asynchronously. ClusterTester does not support such asynchronous processing.
result.status = app::DataModel::ActionReturnStatus(CHIP_ERROR_INCORRECT_STATE);
ChipLogError(
Test, "InvokeCommand returned nullopt, but neither HasResponse nor HasStatus is true. Setting error status.");
}
}
// If command was successful and there's a response, decode it (skip for NullObjectType)
if constexpr (!std::is_same_v<ResponseType, app::DataModel::NullObjectType>)
{
if (result.status.has_value() && result.status->IsSuccess() && mHandler.HasResponse())
{
ResponseType decodedResponse;
CHIP_ERROR decodeError = mHandler.DecodeResponse(decodedResponse);
if (decodeError == CHIP_NO_ERROR)
{
result.response = std::move(decodedResponse);
}
else
{
// Decode failed; reflect error in status and log
result.status = app::DataModel::ActionReturnStatus(decodeError);
ChipLogError(Test, "DecodeResponse failed: %s", decodeError.AsString());
}
}
}
return result;
}
// convenience method: most requests have a `GetCommandId` (and GetClusterId() as well).
template <typename RequestType, typename ResponseType = typename RequestType::ResponseType>
[[nodiscard]] InvokeResult<ResponseType> Invoke(const RequestType & request)
{
return Invoke(RequestType::GetCommandId(), request);
}
// Returns the next generated event from the event generator in the test server cluster context
std::optional<LogOnlyEvents::EventInformation> GetNextGeneratedEvent()
{
return mTestServerClusterContext.EventsGenerator().GetNextEvent();
}
std::vector<app::AttributePathParams> & GetDirtyList() { return mTestServerClusterContext.ChangeListener().DirtyList(); }
// Returns true if the given attribute appears in the dirty list.
// Will construct the attribute path using the first path returned by `GetPaths()` on the cluster.
// Will VerifyOrDie that `GetPaths()` returns exactly one path.
bool IsAttributeDirty(AttributeId attributeId)
{
const auto & paths = mCluster.GetPaths();
VerifyOrDie(paths.size() == 1);
app::AttributePathParams target(paths[0].mEndpointId, paths[0].mClusterId, attributeId);
const auto & list = GetDirtyList();
return std::find(list.begin(), list.end(), target) != list.end();
}
void SetFabricIndex(FabricIndex fabricIndex) { mHandler.SetFabricIndex(fabricIndex); }
void SetSubjectDescriptor(const Access::SubjectDescriptor & subjectDescriptor)
{
mHandler.SetSubjectDescriptor(subjectDescriptor);
}
FabricTestFixture * GetFabricHelper() { return mFabricTestFixture; }
private:
bool VerifyClusterPathsValid()
{
auto paths = mCluster.GetPaths();
if (paths.size() != 1)
{
ChipLogError(Test, "cluster.GetPaths() did not return exactly one path");
return false;
}
return true;
}
bool IsAttributeInAttributeList(AttributeId attr_id)
{
// Attributes are listed by path, so this is only correct for single-path clusters.
VerifyOrDie(mCluster.GetPaths().size() == 1);
ReadOnlyBufferBuilder<app::DataModel::AttributeEntry> builder;
if (CHIP_ERROR err = mCluster.Attributes(mCluster.GetPaths()[0], builder); err != CHIP_NO_ERROR)
{
ChipLogError(Test, "Failed to get attribute list: %" CHIP_ERROR_FORMAT, err.Format());
return false;
}
ReadOnlyBuffer<app::DataModel::AttributeEntry> attributeEntries = builder.TakeBuffer();
return std::any_of(attributeEntries.begin(), attributeEntries.end(),
[&](const app::DataModel::AttributeEntry & entry) { return entry.attributeId == attr_id; });
}
bool IsCommandAnAcceptedCommand(CommandId commandId)
{
// Commands are listed by path, so this is only correct for single-path clusters.
VerifyOrDie(mCluster.GetPaths().size() == 1);
ReadOnlyBufferBuilder<app::DataModel::AcceptedCommandEntry> builder;
if (CHIP_ERROR err = mCluster.AcceptedCommands(mCluster.GetPaths()[0], builder); err != CHIP_NO_ERROR)
{
ChipLogError(Test, "Failed to get accepted commands: %" CHIP_ERROR_FORMAT, err.Format());
return false;
}
ReadOnlyBuffer<app::DataModel::AcceptedCommandEntry> commandEntries = builder.TakeBuffer();
return std::any_of(commandEntries.begin(), commandEntries.end(),
[&](const app::DataModel::AcceptedCommandEntry & entry) { return entry.commandId == commandId; });
}
TestServerClusterContext mTestServerClusterContext{};
app::ServerClusterInterface & mCluster;
// Buffer size for TLV encoding/decoding of command payloads.
// 256 bytes was chosen as a conservative upper bound for typical command payloads in tests.
// All command payloads used in tests must fit within this buffer; tests with larger payloads will fail.
// If protocol or test requirements change, this value may need to be increased.
// Increased to 1024 to support certificate management commands which include X.509 certificates (~400+ bytes)
static constexpr size_t kTlvBufferSize = 1024;
chip::Testing::MockCommandHandler mHandler;
uint8_t mTlvBuffer[kTlvBufferSize];
std::vector<std::unique_ptr<ReadOperation>> mReadOperations;
FabricTestFixture * mFabricTestFixture;
};
} // namespace Testing
} // namespace chip