This commit is contained in:
Bartosz Wieczorek 2023-07-19 12:09:02 +02:00
parent dcab16178d
commit 9d6790c3cf
10 changed files with 299 additions and 142 deletions

View File

@ -1,7 +1,9 @@
set(CMAKE_SHARED_LINKER_FLAGS "-fpic -static-libstdc++ -fvisibility=hidden -ffunction-sections -fdata-sections -fwhole-program ")
add_library(
rublon-ssh SHARED pam.cpp pam.hpp rublon.hpp curl.hpp span.hpp sign.hpp CoreHandler.hpp configuration.hpp
)
#set(CMAKE_SHARED_LINKER_FLAGS "-fpic -static-libstdc++ -fvisibility=hidden -ffunction-sections -fdata-sections -fwhole-program")
target_link_options(rublon-ssh PUBLIC -fpic -shared)
target_link_libraries(rublon-ssh -lcurl -lssl -lcrypto)

View File

@ -27,6 +27,7 @@ template < typename Impl >
class CoreHandlerInterface {
public:
tl::expected< rublon::Document, CoreHandlerError > request(std::string_view path, const rublon::Document & body) const {
rublon::debugLog("[TMP] request",__PRETTY_FUNCTION__);
return static_cast< const Impl * >(this)->request(path, body);
}
};
@ -36,17 +37,19 @@ class CoreHandler : public CoreHandlerInterface< CoreHandler< HttpHandler > > {
std::string secretKey;
std::string url;
std::pmr::string xRublonSignature(std::pmr::memory_resource * mr, std::string_view body) const {
return signData(body, secretKey.c_str()).data();
std::pmr::string xRublonSignature(std::pmr::memory_resource & mr, std::string_view body) const {
return {signData(body, secretKey.c_str()).data(), &mr};
}
void signRequest(std::pmr::monotonic_buffer_resource & mr, Request & request) const {
request.headers["X-Rublon-Signature"] = xRublonSignature(&mr, request.body);
rublon::debugLog("[TMP]",__PRETTY_FUNCTION__);
request.headers["X-Rublon-Signature"] = xRublonSignature(mr, request.body);
}
bool responseSigned(const Response & response) const {
auto xRubResp = response.headers.at("x-rublon-signature");
auto sign = signData(response.body, secretKey);
rublon::debugLog("[TMP]",__PRETTY_FUNCTION__);
const auto & xRubResp = response.headers.at("x-rublon-signature");
const auto & sign = signData(response.body, secretKey);
return xRubResp == sign.data();
}
@ -59,10 +62,12 @@ class CoreHandler : public CoreHandlerInterface< CoreHandler< HttpHandler > > {
CoreHandler(const rublon::Configuration & config) : secretKey{config.parameters.secretKey}, url{config.parameters.apiServer} {}
tl::expected< rublon::Document, CoreHandlerError > request(std::string_view path, const rublon::Document & body) const {
rublon::debugLog("[TMP] request",__PRETTY_FUNCTION__);
std::byte _buffer[16 * 1024];
std::pmr::monotonic_buffer_resource mr{_buffer, sizeof(_buffer)};
rublon::RapidJSONPMRAlloc alloc{&mr};
rublon::StringBuffer jsonStr{&alloc};
rublon::Writer writer{jsonStr, &alloc};
@ -73,31 +78,33 @@ class CoreHandler : public CoreHandlerInterface< CoreHandler< HttpHandler > > {
request.headers["Accept"] = "application/json";
request.body = jsonStr.GetString();
rublon::debugLog("[TMP] request", "sign");
signRequest(mr, request);
std::pmr::string uri{url + path.data(), &mr};
auto response = http.request(uri, request);
// std::pmr::string uri{url + path.data(), &mr};
if(not response.has_value()) {
// rublon::debugLog("[TMP] send", request.body.c_str());
// auto response = http.request(uri, request);
// if(not response.has_value()) {
return tl::unexpected{CoreHandlerError::ConnectionError};
}
if(not responseSigned(*response)) {
return tl::unexpected{CoreHandlerError::BadSigature};
}
// }
// if(not responseSigned(*response)) {
// return tl::unexpected{CoreHandlerError::BadSigature};
// }
rublon::Document resp{&alloc};
resp.Parse(response->body.c_str());
// rublon::Document resp{&alloc};
// resp.Parse(response->body.c_str());
if(resp.HasParseError() or not resp.HasMember("result")) {
return tl::unexpected{CoreHandlerError::BrokenData};
}
if(resp["result"].HasMember("exception")) {
return tl::unexpected{CoreHandlerError{CoreHandlerError::CoreException, resp["result"]["exception"].GetString()}};
}
// if(resp.HasParseError() or not resp.HasMember("result")) {
// return tl::unexpected{CoreHandlerError::BrokenData};
// }
// if(resp["result"].HasMember("exception")) {
// return tl::unexpected{CoreHandlerError{CoreHandlerError::CoreException, resp["result"]["exception"].GetString()}};
// }
return resp;
// return resp;
}
};

View File

@ -11,12 +11,8 @@
#include <memory_resource>
#include "span.hpp"
#include "utils.hpp"
/// TODO rename file to rublon/Core.hpp
/// TODO Create rublon utils
namespace rublon {
static size_t WriteMemoryCallback(void * contents, size_t size, size_t nmemb, void * userp) {
size_t realsize = size * nmemb;
@ -42,6 +38,8 @@ class CURL {
CURL() : curl{std::unique_ptr< ::CURL, void (*)(::CURL *) >(curl_easy_init(), curl_easy_cleanup)} {}
std::optional< Response > request(std::string_view uri, const Request &request) const {
rublon::debugLog("[TMP] request",__PRETTY_FUNCTION__);
std::string response_data;
response_data.reserve(1000);
@ -63,7 +61,9 @@ class CURL {
curl_easy_setopt(curl.get(), CURLOPT_HEADER, 1);
curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, WriteMemoryCallback);
curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response_data);
rublon::debugLog("[TMP] calling curl",__PRETTY_FUNCTION__);
auto res = curl_easy_perform(curl.get());
if(res != CURLE_OK) {
// debugLog("No response from Rublon server (perform)", "");

View File

@ -74,15 +74,4 @@ using Value = rapidjson::GenericValue< rapidjson::UTF8<>, RapidJSONPMRAlloc >
using StringBuffer = rapidjson::GenericStringBuffer< rapidjson::UTF8<>, RapidJSONPMRAlloc >;
using Writer = rapidjson::Writer< StringBuffer, rapidjson::UTF8<>, rapidjson::UTF8<>, RapidJSONPMRAlloc >;
// using value = GenericValue< UTF8<>, RapidJSONPMRAlloc >;
// RapidJSONPMRAlloc alloc{&mr};
// GenericDocument< UTF8<>, RapidJSONPMRAlloc > d(&alloc);
// d.SetObject();
// GenericDocument< UTF8<>, RapidJSONPMRAlloc >::AllocatorType & allocator = d.GetAllocator();
// d.AddMember("systemToken", "DUPA", allocator);
// d.AddMember("username", "$USER", allocator);
// d.AddMember("userEmail", "$USER_EMAIL", allocator);
} // namespace rublon

View File

@ -1,36 +1,56 @@
#include <security/pam_appl.h>
#include <security/pam_modules.h>
#include <security/pam_misc.h>
#include <security/pam_client.h>
#include <security/pam_ext.h>
#include <security/pam_misc.h>
#include <security/pam_modules.h>
#include <rapidjson/rapidjson.h>
#include <iostream>
#include "curl.hpp"
#include "rublon.hpp"
#include "utils.hpp"
using namespace std;
PAM_EXTERN int pam_sm_setcred( pam_handle_t *pamh, int flags, int argc, const char **argv ) {
namespace {
template < typename T >
using Expected = tl::expected< T, rublon::PamAction >;
Expected< rublon::Method > chooseMethod() {}
Expected< rublon::Confirm > confirm() {}
} // namespace
PAM_EXTERN int pam_sm_setcred(pam_handle_t * pamh, int flags, int argc, const char ** argv) {
rublon::debugLog("[TMP]start", __PRETTY_FUNCTION__);
return PAM_SUCCESS;
}
PAM_EXTERN int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) {
PAM_EXTERN int pam_sm_acct_mgmt(pam_handle_t * pamh, int flags, int argc, const char ** argv) {
rublon::debugLog("[TMP]start", __PRETTY_FUNCTION__);
return PAM_SUCCESS;
}
PAM_EXTERN int pam_sm_authenticate(pam_handle_t * pamh, int flags, int argc, const char ** argv) {
rublon::debugLog("[TMP]start", __PRETTY_FUNCTION__);
auto rublonConfig = rublon::ConfigurationFactory{}.systemConfig();
rublon::debugLog("[TMP]create core handler", "");
rublon::CoreHandler CH{rublonConfig.value()};
rublon::Init{rublonConfig.value()}.fire(CH);
rublon::debugLog("[TMP]start", "");
auto action = rublon::Init{pamh, rublonConfig.value()}
.fire(CH) //
.and_then([](const auto & method) -> Expected< int > {
rublon::debugLog("[TMP]start", "init lambda");
return tl::unexpected{rublon::PamAction::accept};
});
// .and_then([](const auto & value) { return tl::expected< rublon::Configuration, rublon::PamAction >{}; }) //
// .and_then([](const auto & conf) { return tl::expected< rublon::Configuration, rublon::PamAction >{}; });
return PAM_SUCCESS;
}
PAM_EXTERN int pam_sm_authenticate( pam_handle_t *pamh, int flags,int argc, const char **argv ) {
return PAM_SUCCESS;
}
int main(){
pam_sm_acct_mgmt(nullptr, 1, 0, nullptr);
}

View File

@ -18,24 +18,39 @@
#include "utils.hpp"
class PamAction {};
class PamAccept {};
class PamDecline {};
class PAMPrompt {
class LinuxPam {
pam_handle_t * pamh;
public:
LinuxPam(pam_handle_t * handler) : pamh{handler} {}
rublon::NonOwningPtr< const char > ip() const {
const void * ip = NULL;
pam_get_item(pamh, PAM_RHOST, &ip);
if(ip == NULL)
ip = "";
rublon::debugLog(__PRETTY_FUNCTION__, (const char*)ip);
return (const char*)ip;
}
rublon::NonOwningPtr< const char > username() const {
const char * user = NULL;
pam_get_user(pamh, &user, nullptr);
if(user == NULL)
user = "";
rublon::debugLog(__PRETTY_FUNCTION__, user);
return user;
}
template < typename... Ti >
void print(const char * fmt, Ti... ti) const noexcept {
// pam_prompt(pamh, PAM_TEXT_INFO, nullptr, fmt, std::forward<Ti...>(ti...));
pam_prompt(pamh, PAM_TEXT_INFO, nullptr, fmt, std::forward< Ti... >(ti...));
}
template < typename Fun, typename... Ti >
[[nodiscard]] auto scan(Fun && f, const char * fmt, Ti... ti) const noexcept {
char * responseBuffer = nullptr;
pam_prompt(pamh, PAM_TEXT_INFO, &responseBuffer, fmt, std::forward< Ti... >(ti...));
pam_prompt(pamh, PAM_PROMPT_ECHO_ON, &responseBuffer, fmt, std::forward< Ti... >(ti...));
if(responseBuffer) {
auto ret = f(responseBuffer);
free(responseBuffer);
@ -44,20 +59,3 @@ class PAMPrompt {
return std::optional< std::result_of_t< Fun(char *) > >();
}
};
class LinuxPam {
pam_handle_t * pamh;
public:
rublon::NonOwningPtr< const char > ip() const {
const char * ip = NULL;
pam_get_item(pamh, PAM_RHOST, ( const void ** ) &ip);
if(ip == NULL)
ip = "";
return ip;
}
rublon::NonOwningPtr< const char > username() const {
// pam_get_user
}
};

View File

@ -26,85 +26,147 @@ enum class PamAction { accept, decline };
template < typename Impl >
class AuthenticationStep {
public:
template < typename Hander_t >
auto fire(const CoreHandlerInterface< Hander_t > & coreHandler) {
template < typename Handler_t >
auto fire(const CoreHandlerInterface< Handler_t > & coreHandler) {
// log step
// debugLog("Starting %s step", static_cast<Impl*>(this)->stepName );
return static_cast< Impl * >(this)->handle(coreHandler);
}
};
template < typename PamInfo_t = PAMInfo >
class Method : public AuthenticationStep< Method< PamInfo_t > > {
class Confirm : public AuthenticationStep< Confirm > {
public:
Confirm(const Configuration & /*config*/) {}
};
class MethodOTP {};
class MethodSMS {};
class Method : public AuthenticationStep< Method > {
std::string tid;
public:
Method() {}
template < typename Handler_t >
tl::expected< Confirm, PamAction > fire(const CoreHandlerInterface< Handler_t > & coreHandler) {}
private:
std::variant< MethodOTP, MethodSMS > _impl;
};
template < typename PamInfo_t = PAMInfo >
class Init : public AuthenticationStep< Init< PamInfo_t > > {
const char * apiPath = "/api/transaction/init";
const std::string & systemToken;
template < typename Pam_t = LinuxPam >
class MethodFactory {
const Pam_t & pam;
PamInfo_t pamInfo;
public:
MethodFactory(const Pam_t & pam) : pam{pam} {}
template < typename Array_t >
tl::expected< Method, PamAction > create(const Array_t & methods) const {
rublon::debugLog("[TMP] ",__PRETTY_FUNCTION__);
std::pmr::map< int, std::pmr::string > methods_id;
pam.print("%s", "");
int i;
for(const auto & method : methods) {
if(method == "email") {
pam.print("%d: Email Link", i + 1);
methods_id[++i] = "email";
}
if(method == "qrcode") {
pam.print("%d: QR Code", i + 1);
methods_id[++i] = "qrcode";
}
if(method == "totp") {
pam.print("%d: Mobile TOTP", i + 1);
methods_id[++i] = "totp";
}
if(method == "push") {
pam.print("%d: Mobile Push", i + 1);
methods_id[++i] = "push";
}
if(method == "sms") {
pam.print("%d: SMS code", i + 1);
methods_id[++i] = "sms";
}
}
auto methodid = pam.scan([](char * userinput) { return std::stoi(userinput); }, "\nSelect method [1-%d]: ", methods.Size());
pam.print("you selected: %s", methods_id.count(methodid.value_or(0)) ? methods_id.at(methodid.value_or(0)).c_str() : "unknown option");
return tl::unexpected{PamAction::accept};
}
};
template < template < typename > class MethodFactory_t = MethodFactory, typename PamInfo_t = LinuxPam >
class Init : public AuthenticationStep< Init< MethodFactory_t, PamInfo_t > > {
const char * apiPath = "/api/transaction/init";
const std::string & _systemToken;
protected:
PamInfo_t _pamInfo;
MethodFactory_t< PamInfo_t > _methodFactory;
public:
const char * stepName = "Initialization";
Init(const rublon::Configuration & config) : systemToken{config.parameters.systemToken} {}
Init(pam_handle_t * pamHandler, const rublon::Configuration & config)
: _systemToken{config.parameters.systemToken}, _pamInfo{pamHandler}, _methodFactory{_pamInfo} {
rublon::debugLog("[TMP] Init ctor",__PRETTY_FUNCTION__);
}
/// TODO add core handler interface
template < typename Hander_t >
std::variant< Method< PamInfo_t >, PamAction > handle(const CoreHandlerInterface< Hander_t > & handler) const {
tl::expected< Method, PamAction > handle(const CoreHandlerInterface< Hander_t > & coreHandler) const {
rublon::debugLog("[TMP] init handle",__PRETTY_FUNCTION__);
char _buffer[1024];
std::pmr::monotonic_buffer_resource mr{_buffer, 1024};
RapidJSONPMRAlloc alloc{&mr};
Document body{rapidjson::kObjectType, &alloc};
body.AddMember("systemToken", Value{systemToken.c_str(), alloc}, alloc);
body.AddMember("username", Value{pamInfo.username().get(), alloc}, alloc);
body.AddMember("userEmail", "bwi@rublon.com", alloc);
body.AddMember("systemToken", Value{_systemToken.c_str(), alloc}, alloc);
body.AddMember("username", Value{_pamInfo.username().get(), alloc}, alloc);
body.AddMember("userEmail", "bwi@rublon.com", alloc); /// TODO proper username
Value params{rapidjson::kObjectType};
params.AddMember("userIP", Value{pamInfo.ip().get(), alloc}, alloc);
params.AddMember("userIP", Value{_pamInfo.ip().get(), alloc}, alloc);
params.AddMember("appVer", "v.1.6", alloc); /// TODO add version to cmake
params.AddMember("os", "Ubuntu 23.04", alloc); /// TODO add version to cmake
body.AddMember("params", std::move(params), alloc);
rublon::debugLog("[TMP] calling coreHandler",__PRETTY_FUNCTION__);
auto httpResponse = coreHandler.request(apiPath, body);
auto response = handler.request(apiPath, body);
// if(httpResponse.has_value()) {
// rublon::debugLog("[TMP] has response, processing",__PRETTY_FUNCTION__);
// const auto & rublonResponse = httpResponse.value()["response"];
if(response.has_value()) {
std::cout << response.value()["response"]["tid"].GetString();
// std::string tid = rublonResponse["tid"].GetString();
return Method< PamInfo_t >{
// return _methodFactory.create(rublonResponse["methods"].GetArray());
// } else {
// // mostly connectio errors
// switch(httpResponse.error().errorClass) {
// case CoreHandlerError::ErrorClass::BadSigature:
// return tl::unexpected{PamAction::decline};
// case CoreHandlerError::ErrorClass::CoreException: /// TODO exception handling
// return tl::unexpected{PamAction::decline}; /// TODO accept?
// case CoreHandlerError::ErrorClass::ConnectionError:
// return tl::unexpected{PamAction::decline}; /// TODO decline?
// case CoreHandlerError::ErrorClass::BrokenData:
// return tl::unexpected{PamAction::decline};
// }
// }
};
} else {
// mostly connectio errors
switch(response.error().errorClass) {
case CoreHandlerError::ErrorClass::BadSigature:
return PamAction::decline; /// TODO accept?
case CoreHandlerError::ErrorClass::CoreException:
return PamAction::decline; /// TODO accept?
case CoreHandlerError::ErrorClass::ConnectionError:
return PamAction::decline; /// TODO accept?
case CoreHandlerError::ErrorClass::BrokenData:
return PamAction::decline;
}
}
return {PamAction::decline};
return tl::unexpected{PamAction::decline};
}
};
class ConfirmCode : public AuthenticationStep< ConfirmCode > {
public:
ConfirmCode(const Configuration & /*config*/) {}
};
class VerifySSH : public AuthenticationStep< VerifySSH > {
public:

View File

@ -1,21 +1,20 @@
#pragma once
#include <array>
#include <cstring>
#include <openssl/evp.h>
#include <openssl/hmac.h>
#include <security/pam_appl.h>
#include <security/pam_modules.h>
#include <string_view>
#include <array>
namespace rublon {
inline std::array< char, 64 > signData(std::string_view data, std::string_view secretKey) {
std::array< char, 64 > xRublon;
unsigned char md[EVP_MAX_MD_SIZE] = {0};
unsigned int md_len;
HMAC(EVP_sha256(), secretKey.data(), secretKey.size(), ( unsigned const char * ) data.data(), data.size(), md, &md_len);
int i;
for(i = 0; i < 32; i++)
sprintf(&xRublon[i * 2], "%02x", ( unsigned int ) md[i]);

View File

@ -9,9 +9,26 @@
#include <string_view>
#include <alloca.h>
#include <cassert>
#include <security/pam_appl.h>
#include <security/pam_modules.h>
#include <syslog.h>
namespace rublon {
inline void debugLog(const char * message1, const char * message2) {
auto file_name = "/tmp/rublon.log";
auto fp = fopen(file_name, "a");
fprintf(fp, "[%s] %s %s\n", "cmake version", message1, message2);
fflush(fp);
fclose(fp);
// openlog("pam_rublon", LOG_CONS | LOG_PID | LOG_NDELAY, LOG_LOCAL1);
// syslog(LOG_NOTICE, "[%s] %s %s", "cmake version", message1, message2);
// closelog();
}
template < typename T >
class NonOwningPtr {
T * object;
@ -33,6 +50,12 @@ class NonOwningPtr {
constexpr operator T *() noexcept {
return get();
}
constexpr T * operator->() {
return get();
}
constexpr const T * operator->() const {
return get();
}
};
namespace details {

View File

@ -9,50 +9,107 @@ using namespace rublon;
using namespace testing;
namespace {
Configuration conf;
Configuration conf;
}
class CoreHandlerMock : public CoreHandlerInterface< CoreHandlerMock > {
public:
CoreHandlerMock() {}
MOCK_METHOD(( tl::expected< Document, CoreHandlerError > ), request, ( std::string_view, const Document & ), (const));
CoreHandlerMock & statusPending() {
gen.status = "pending";
return *this;
}
CoreHandlerMock & brokenBody() {
gen.generateBrokenData = true;
return *this;
}
operator tl::expected< Document, CoreHandlerError >() {
auto body = gen.generateBody();
rublon::Document doc;
doc.Parse(body.c_str());
return doc;
}
CoreResponseGenerator gen;
};
class PamInfoMock {
public:
PamInfoMock(pam_handle_t *) {}
MOCK_METHOD(rublon::NonOwningPtr< const char >, ip, (), (const));
MOCK_METHOD(rublon::NonOwningPtr< const char >, username, (), (const));
};
template < typename Pam >
class MethodFactoryMock {
public:
MethodFactoryMock(const Pam &) {}
template < typename Array_t >
tl::expected< Method, PamAction > create(const Array_t & methods) const {}
};
class InitTestable : public Init< MethodFactoryMock, PamInfoMock > {
public:
InitTestable(const rublon::Configuration & conf) : Init{nullptr, conf} {}
PamInfoMock & pam() {
return _pamInfo;
}
};
class RublonHttpInitTest : public testing::Test {
public:
void expectDefaultPamInfo() {
EXPECT_CALL(pam, ip()).WillOnce(Return("192.168.0.1"));
EXPECT_CALL(pam, username()).WillOnce(Return("bwi"));
}
RublonHttpInitTest() : coreHandler{}, sut{conf}, pam{sut.pam()} {}
CoreHandlerMock coreHandler;
Init<> sut{conf};
InitTestable sut{conf};
PamInfoMock & pam;
};
using CoreReturn = tl::expected< Document, CoreHandlerError >;
TEST_F(RublonHttpInitTest, initializationSendsRequestOnGoodPath) {
EXPECT_CALL(coreHandler, request("/api/transaction/init", _)).WillOnce(Return(tl::unexpected{CoreHandlerError{CoreHandlerError::BadSigature}}));
expectDefaultPamInfo();
EXPECT_CALL(coreHandler, request("/api/transaction/init", _))
.WillOnce(Return(tl::unexpected{CoreHandlerError{CoreHandlerError::BadSigature}}));
sut.handle(coreHandler);
}
TEST_F(RublonHttpInitTest, returnMethods){
MATCHER_P(HoldsPamAction, action, "") {
return not arg.has_value() && arg.error() == action;
}
TEST_F(RublonHttpInitTest, rublon_Accept_pamLoginWhenThereIsNoConnection) {
expectDefaultPamInfo();
EXPECT_CALL(coreHandler, request(_, _)).WillOnce(Return(tl::unexpected{CoreHandlerError{CoreHandlerError::ConnectionError}}));
EXPECT_THAT(sut.handle(coreHandler), HoldsPamAction(PamAction::decline));
}
TEST_F(RublonHttpInitTest, rublon_Decline_pamLoginWhenServerHasBadSignature) {
expectDefaultPamInfo();
EXPECT_CALL(coreHandler, request(_, _)).WillOnce(Return(tl::unexpected{CoreHandlerError{CoreHandlerError::BadSigature}}));
EXPECT_THAT(sut.handle(coreHandler), HoldsPamAction(PamAction::decline));
}
TEST_F(RublonHttpInitTest, rublon_Decline_pamLoginWhenServerReturnsBrokenData) {
expectDefaultPamInfo();
EXPECT_CALL(coreHandler, request(_, _)).WillOnce(Return(tl::unexpected{CoreHandlerError{CoreHandlerError::BrokenData}}));
EXPECT_THAT(sut.handle(coreHandler), HoldsPamAction(PamAction::decline));
}
TEST_F(RublonHttpInitTest, rublon_Decline_pamLoginWhenServerReturnsCoreException) {
expectDefaultPamInfo();
EXPECT_CALL(coreHandler, request(_, _)).WillOnce(Return(tl::unexpected{CoreHandlerError{CoreHandlerError::CoreException}}));
EXPECT_THAT(sut.handle(coreHandler), HoldsPamAction(PamAction::decline));
}