fangorn/sunshine-qemu
public
ref:main
/**
* @file tests/unit/test_http_pairing.cpp
* @brief Test src/nvhttp.cpp HTTP pairing process
*/
// test includes
#include "../tests_common.h"
// standard includes
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <format>
#include <future>
#include <mutex>
#include <sstream>
#include <thread>
#include <vector>
// lib includes
#include <Simple-Web-Server/client_http.hpp>
#include <Simple-Web-Server/server_http.hpp>
// local includes
#include <src/config.h>
#include <src/nvhttp.h>
using namespace nvhttp;
struct pairing_input {
std::shared_ptr<pair_session_t> session;
/**
* Normally server challenge is generated by the server, but for testing purposes
* we can override it with a custom value. This way the process is deterministic.
*/
std::string override_server_challenge;
std::string pin;
std::string client_challenge;
std::string server_challenge_resp;
std::string client_pairing_secret;
};
struct pairing_output {
bool phase_1_success;
bool phase_2_success;
bool phase_3_success;
bool phase_4_success;
};
const std::string PRIVATE_KEY = R"(-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLePNlWN06FLlM
ujWzIX8UICO7SWfH5DXlafVjpxwi/WCkdO6FxixqRNGu71wMvJXFbDlNR8fqX2xo
+eq17J3uFKn+qdjmP3L38bkqxhoJ/nCrXkeGyCTQ+Daug63ZYSJeW2Mmf+LAR5/i
/fWYfXpSlbcf5XJQPEWvENpLqWu+NOU50dJXIEVYpUXRx2+x4ZbwkH7tVJm94L+C
OUyiJKQPyWgU2aFsyJGwHFfePfSUpfYHqbHZV/ILpY59VJairBwE99bx/mBvMI7a
hBmJTSDuDffJcPDhFF5kZa0UkQPrPvhXcQaSRti7v0VonEQj8pTSnGYr9ktWKk92
wxDyn9S3AgMBAAECggEAbEhQ14WELg2rUz7hpxPTaiV0fo4hEcrMN+u8sKzVF3Xa
QYsNCNoe9urq3/r39LtDxU3D7PGfXYYszmz50Jk8ruAGW8WN7XKkv3i/fxjv8JOc
6EYDMKJAnYkKqLLhCQddX/Oof2udg5BacVWPpvhX6a1NSEc2H6cDupfwZEWkVhMi
bCC3JcNmjFa8N7ow1/5VQiYVTjpxfV7GY1GRe7vMvBucdQKH3tUG5PYXKXytXw/j
KDLaECiYVT89KbApkI0zhy7I5g3LRq0Rs5fmYLCjVebbuAL1W5CJHFJeFOgMKvnO
QSl7MfHkTnzTzUqwkwXjgNMGsTosV4UloL9gXVF6GQKBgQD5fI771WETkpaKjWBe
6XUVSS98IOAPbTGpb8CIhSjzCuztNAJ+0ey1zklQHonMFbdmcWTkTJoF3ECqAos9
vxB4ROg+TdqGDcRrXa7Twtmhv66QvYxttkaK3CqoLX8CCTnjgXBCijo6sCpo6H1T
+y55bBDpxZjNFT5BV3+YPBfWQwKBgQDQyNt+saTqJqxGYV7zWQtOqKORRHAjaJpy
m5035pky5wORsaxQY8HxbsTIQp9jBSw3SQHLHN/NAXDl2k7VAw/axMc+lj9eW+3z
2Hv5LVgj37jnJYEpYwehvtR0B4jZnXLyLwShoBdRPkGlC5fs9+oWjQZoDwMLZfTg
eZVOJm6SfQKBgQDfxYcB/kuKIKsCLvhHaSJpKzF6JoqRi6FFlkScrsMh66TCxSmP
0n58O0Cqqhlyge/z5LVXyBVGOF2Pn6SAh4UgOr4MVAwyvNp2aprKuTQ2zhSnIjx4
k0sGdZ+VJOmMS/YuRwUHya+cwDHp0s3Gq77tja5F38PD/s/OD8sUIqJGvQKBgBfI
6ghy4GC0ayfRa+m5GSqq14dzDntaLU4lIDIAGS/NVYDBhunZk3yXq99Mh6/WJQVf
Uc77yRsnsN7ekeB+as33YONmZm2vd1oyLV1jpwjfMcdTZHV8jKAGh1l4ikSQRUoF
xTdMb5uXxg6xVWtvisFq63HrU+N2iAESmMnAYxRZAoGAVEFJRRjPrSIUTCCKRiTE
br+cHqy6S5iYRxGl9riKySBKeU16fqUACIvUqmqlx4Secj3/Hn/VzYEzkxcSPwGi
qMgdS0R+tacca7NopUYaaluneKYdS++DNlT/m+KVHqLynQr54z1qBlThg9KGrpmM
LGZkXtQpx6sX7v3Kq56PkNk=
-----END PRIVATE KEY-----)";
const std::string PUBLIC_CERT = R"(-----BEGIN CERTIFICATE-----
MIIC6zCCAdOgAwIBAgIBATANBgkqhkiG9w0BAQsFADA5MQswCQYDVQQGEwJJVDEW
MBQGA1UECgwNR2FtZXNPbldoYWxlczESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIy
MDQwOTA5MTYwNVoXDTQyMDQwNDA5MTYwNVowOTELMAkGA1UEBhMCSVQxFjAUBgNV
BAoMDUdhbWVzT25XaGFsZXMxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBAMt482VY3ToUuUy6NbMhfxQgI7tJZ8fkNeVp
9WOnHCL9YKR07oXGLGpE0a7vXAy8lcVsOU1Hx+pfbGj56rXsne4Uqf6p2OY/cvfx
uSrGGgn+cKteR4bIJND4Nq6DrdlhIl5bYyZ/4sBHn+L99Zh9elKVtx/lclA8Ra8Q
2kupa7405TnR0lcgRVilRdHHb7HhlvCQfu1Umb3gv4I5TKIkpA/JaBTZoWzIkbAc
V9499JSl9gepsdlX8guljn1UlqKsHAT31vH+YG8wjtqEGYlNIO4N98lw8OEUXmRl
rRSRA+s++FdxBpJG2Lu/RWicRCPylNKcZiv2S1YqT3bDEPKf1LcCAwEAATANBgkq
hkiG9w0BAQsFAAOCAQEAqPBqzvDjl89pZMll3Ge8RS7HeDuzgocrhOcT2jnk4ag7
/TROZuISjDp6+SnL3gPEt7E2OcFAczTg3l/wbT5PFb6vM96saLm4EP0zmLfK1FnM
JDRahKutP9rx6RO5OHqsUB+b4jA4W0L9UnXUoLKbjig501AUix0p52FBxu+HJ90r
HlLs3Vo6nj4Z/PZXrzaz8dtQ/KJMpd/g/9xlo6BKAnRk5SI8KLhO4hW6zG0QA56j
X4wnh1bwdiidqpcgyuKossLOPxbS786WmsesaAWPnpoY6M8aija+ALwNNuWWmyMg
9SVDV76xJzM36Uq7Kg3QJYTlY04WmPIdJHkCtXWf9g==
-----END CERTIFICATE-----)";
struct PairingTest: BaseTest, testing::WithParamInterface<std::tuple<pairing_input, pairing_output>> {
/**
* @brief Isolate pairing authorization state and suppress persistence for the test.
*/
void SetUp() override {
BaseTest::SetUp();
original_fresh_state = config::sunshine.flags[config::flag::FRESH_STATE];
config::sunshine.flags[config::flag::FRESH_STATE] = true;
nvhttp::test_support::reset_client_state();
}
/**
* @brief Restore the caller's fresh-state configuration after the pairing test.
*/
void TearDown() override {
nvhttp::test_support::reset_client_state();
config::sunshine.flags[config::flag::FRESH_STATE] = original_fresh_state;
BaseTest::TearDown();
}
private:
bool original_fresh_state; ///< Fresh-state flag restored after each pairing test.
};
TEST_P(PairingTest, Run) {
auto [input, expected] = GetParam();
boost::property_tree::ptree tree;
setup(PRIVATE_KEY, PUBLIC_CERT);
// phase 1
getservercert(*input.session, tree, input.pin);
ASSERT_EQ(tree.get<int>("root.paired") == 1, expected.phase_1_success);
if (!expected.phase_1_success) {
return;
}
// phase 2
clientchallenge(*input.session, tree, input.client_challenge);
ASSERT_EQ(tree.get<int>("root.paired") == 1, expected.phase_2_success);
if (!expected.phase_2_success) {
return;
}
// phase 3
serverchallengeresp(*input.session, tree, input.server_challenge_resp);
ASSERT_EQ(tree.get<int>("root.paired") == 1, expected.phase_3_success);
if (!expected.phase_3_success) {
return;
}
input.session->serverchallenge = input.override_server_challenge;
// phase 4
auto input_client_cert = input.session->client.cert; // Will be moved
clientpairingsecret(*input.session, tree, input.client_pairing_secret);
ASSERT_EQ(tree.get<int>("root.paired") == 1, expected.phase_4_success);
ASSERT_TRUE(input.session->completion->result.has_value());
EXPECT_EQ(*input.session->completion->result, expected.phase_4_success);
if (expected.phase_4_success) {
ASSERT_TRUE(nvhttp::test_support::authorize_client_certificate(input_client_cert));
}
}
INSTANTIATE_TEST_SUITE_P(
TestWorkingPairing,
PairingTest,
testing::Values(
std::make_tuple(
pairing_input {
.session = std::make_shared<pair_session_t>(
pair_session_t {
.client = {
.uniqueID = "1234",
.cert = PUBLIC_CERT,
.name = "test"
},
.async_insert_pin = {.salt = "ff5dc6eda99339a8a0793e216c4257c4"}
}
),
.override_server_challenge = util::from_hex_vec("AAAAAAAAAAAAAAAA", true),
.pin = "5338",
/* AES("CLIENT CHALLENGE") */
.client_challenge = util::from_hex_vec("741CD3D6890C16DA39D53BCA0893AAF0", true),
/* SHA = SHA265(server_challenge + public cert signature + "SECRET ") = "6493DAE49C913E1AEAF37C1072F71D664B72B2C4DA1FFB4720BECE0D929E008A"
* AES( SHA ) */
.server_challenge_resp = util::from_hex_vec("920BABAE9F7599AA1CA8EC87FB3454C91872A7D8D5127DDC176C2FDAE635CF7A", true),
/* secret + x509 signature */
.client_pairing_secret = util::from_hex_vec("000102030405060708090A0B0C0D0EFF" // secret
"9BB74D8DE2FF006C3F47FC45EFDAA97D433783AFAB3ACD85CA7ED2330BB2A7BD18A5B044AF8CAC177116FAE8A6E8E44653A8944A0F8EA138B2E013756D847D2C4FC52F736E2E7E9B4154712B18F8307B2A161E010F0587744163E42ECA9EA548FC435756EDCF1FEB94037631ABB72B29DDAC0EA5E61F2DBFCC3B20AA021473CC85AC98D88052CA6618ED1701EFBF142C18D5E779A3155B84DF65057D4823EC194E6DF14006793E8D7A3DCCE20A911636C4E01ECA8B54B9DE9F256F15DE9A980EA024B30D77579140D45EC220C738164BDEEEBF7364AE94A5FF9B784B40F2E640CE8603017DEEAC7B2AD77B807C643B7B349C110FE15F94C7B3D37FF15FDFBE26",
true)
},
pairing_output {true, true, true, true}
),
// Testing that when passing some empty values we aren't triggering any exception
std::make_tuple(pairing_input {
.session = std::make_shared<pair_session_t>(pair_session_t {.client = {}, .async_insert_pin = {.salt = "ff5dc6eda99339a8a0793e216c4257c4"}}),
.override_server_challenge = {},
.pin = {},
.client_challenge = {},
.server_challenge_resp = {},
.client_pairing_secret = util::from_hex_vec("000102030405060708090A0B0C0D0EFFxDEADBEEF", true),
},
// Only phase 4 will fail, when we check what has been exchanged
pairing_output {true, true, true, false}),
// Testing that when passing some empty values we aren't triggering any exception
std::make_tuple(pairing_input {
.session = std::make_shared<pair_session_t>(pair_session_t {.client = {.cert = PUBLIC_CERT}, .async_insert_pin = {.salt = "ff5dc6eda99339a8a0793e216c4257c4"}}),
.override_server_challenge = {},
.pin = {},
.client_challenge = {},
.server_challenge_resp = {},
.client_pairing_secret = util::from_hex_vec("000102030405060708090A0B0C0D0EFFxDEADBEEF", true),
},
// Only phase 4 will fail, when we check what has been exchanged
pairing_output {true, true, true, false})
)
);
INSTANTIATE_TEST_SUITE_P(
TestFailingPairing,
PairingTest,
testing::Values(
/**
* Wrong PIN
*/
std::make_tuple(
pairing_input {
.session = std::make_shared<pair_session_t>(
pair_session_t {
.client = {
.uniqueID = "1234",
.cert = PUBLIC_CERT,
.name = "test"
},
.async_insert_pin = {.salt = "ff5dc6eda99339a8a0793e216c4257c4"}
}
),
.override_server_challenge = util::from_hex_vec("AAAAAAAAAAAAAAAA", true),
.pin = "0000",
.client_challenge = util::from_hex_vec("741CD3D6890C16DA39D53BCA0893AAF0", true),
.server_challenge_resp = util::from_hex_vec("920BABAE9F7599AA1CA8EC87FB3454C91872A7D8D5127DDC176C2FDAE635CF7A", true),
.client_pairing_secret = util::from_hex_vec("000102030405060708090A0B0C0D0EFF" // secret
"9BB74D8DE2FF006C3F47FC45EFDAA97D433783AFAB3ACD85CA7ED2330BB2A7BD18A5B044AF8CAC177116FAE8A6E8E44653A8944A0F8EA138B2E013756D847D2C4FC52F736E2E7E9B4154712B18F8307B2A161E010F0587744163E42ECA9EA548FC435756EDCF1FEB94037631ABB72B29DDAC0EA5E61F2DBFCC3B20AA021473CC85AC98D88052CA6618ED1701EFBF142C18D5E779A3155B84DF65057D4823EC194E6DF14006793E8D7A3DCCE20A911636C4E01ECA8B54B9DE9F256F15DE9A980EA024B30D77579140D45EC220C738164BDEEEBF7364AE94A5FF9B784B40F2E640CE8603017DEEAC7B2AD77B807C643B7B349C110FE15F94C7B3D37FF15FDFBE26",
true)
},
pairing_output {true, true, true, false}
),
/**
* Wrong client challenge
*/
std::make_tuple(pairing_input {.session = std::make_shared<pair_session_t>(pair_session_t {.client = {.uniqueID = "1234", .cert = PUBLIC_CERT, .name = "test"}, .async_insert_pin = {.salt = "ff5dc6eda99339a8a0793e216c4257c4"}}), .override_server_challenge = util::from_hex_vec("AAAAAAAAAAAAAAAA", true), .pin = "5338", .client_challenge = util::from_hex_vec("741CD3D6890C16DA39D53BCA0893AAF0", true), .server_challenge_resp = util::from_hex_vec("WRONG", true),
.client_pairing_secret = util::from_hex_vec("000102030405060708090A0B0C0D0EFF" // secret
"9BB74D8DE2FF006C3F47FC45EFDAA97D433783AFAB3ACD85CA7ED2330BB2A7BD18A5B044AF8CAC177116FAE8A6E8E44653A8944A0F8EA138B2E013756D847D2C4FC52F736E2E7E9B4154712B18F8307B2A161E010F0587744163E42ECA9EA548FC435756EDCF1FEB94037631ABB72B29DDAC0EA5E61F2DBFCC3B20AA021473CC85AC98D88052CA6618ED1701EFBF142C18D5E779A3155B84DF65057D4823EC194E6DF14006793E8D7A3DCCE20A911636C4E01ECA8B54B9DE9F256F15DE9A980EA024B30D77579140D45EC220C738164BDEEEBF7364AE94A5FF9B784B40F2E640CE8603017DEEAC7B2AD77B807C643B7B349C110FE15F94C7B3D37FF15FDFBE26",
true)},
pairing_output {true, true, true, false}),
/**
* Wrong signature
*/
std::make_tuple(pairing_input {.session = std::make_shared<pair_session_t>(pair_session_t {.client = {.uniqueID = "1234", .cert = PUBLIC_CERT, .name = "test"}, .async_insert_pin = {.salt = "ff5dc6eda99339a8a0793e216c4257c4"}}), .override_server_challenge = util::from_hex_vec("AAAAAAAAAAAAAAAA", true), .pin = "5338", .client_challenge = util::from_hex_vec("741CD3D6890C16DA39D53BCA0893AAF0", true), .server_challenge_resp = util::from_hex_vec("920BABAE9F7599AA1CA8EC87FB3454C91872A7D8D5127DDC176C2FDAE635CF7A", true),
.client_pairing_secret = util::from_hex_vec("000102030405060708090A0B0C0D0EFF" // secret
"NOSIGNATURE", // Wrong signature
true)},
pairing_output {true, true, true, false}),
/**
* null values (phase 1)
*/
std::make_tuple(pairing_input {.session = std::make_shared<pair_session_t>()}, pairing_output {.phase_1_success = false}),
/**
* null values (phase 4, phase 2 and 3 have no reason to fail since we are running them in order)
*/
std::make_tuple(pairing_input {.session = std::make_shared<pair_session_t>(pair_session_t {.async_insert_pin = {.salt = "ff5dc6eda99339a8a0793e216c4257c4"}})}, pairing_output {true, true, true, false})
)
);
TEST(PairingTest, OutOfOrderCalls) {
boost::property_tree::ptree tree;
setup(PRIVATE_KEY, PUBLIC_CERT);
pair_session_t sess {};
clientchallenge(sess, tree, "test");
ASSERT_FALSE(tree.get<int>("root.paired") == 1);
serverchallengeresp(sess, tree, "test");
ASSERT_FALSE(tree.get<int>("root.paired") == 1);
clientpairingsecret(sess, tree, "test");
ASSERT_FALSE(tree.get<int>("root.paired") == 1);
// This should work, it's the first time we call it
sess.async_insert_pin.salt = "ff5dc6eda99339a8a0793e216c4257c4";
getservercert(sess, tree, "test");
ASSERT_TRUE(tree.get<int>("root.paired") == 1);
// Calling it again should fail
getservercert(sess, tree, "test");
ASSERT_FALSE(tree.get<int>("root.paired") == 1);
}
namespace {
/**
* @brief Stream buffer that holds console input until a pairing test releases it.
*/
class gated_input_buffer: public std::streambuf {
public:
/**
* @brief Make the buffered input available to its reader.
*/
void release() {
{
std::lock_guard lock {mutex_};
released_ = true;
}
condition_.notify_all();
}
protected:
/**
* @brief Wait for release and expose the buffered PIN to the input stream.
*
* @return The first buffered character, or end-of-file after it is consumed.
*/
int_type underflow() override {
std::unique_lock lock {mutex_};
condition_.wait(lock, [this]() {
return released_;
});
if (gptr() == nullptr) {
setg(input_.data(), input_.data(), input_.data() + input_.size());
}
return gptr() == egptr() ? traits_type::eof() : traits_type::to_int_type(*gptr());
}
private:
std::string input_ = "1234\n"; ///< PIN text released to the pairing handler.
std::mutex mutex_; ///< Protects the release flag.
std::condition_variable condition_; ///< Wakes the blocked input reader.
bool released_ = false; ///< Whether the reader may consume the PIN.
};
/**
* @brief Restore the process input stream after a console-PIN test.
*/
class cin_buffer_guard {
public:
/**
* @brief Redirect standard input to a test buffer.
*
* @param replacement Stream buffer to read during the test.
*/
explicit cin_buffer_guard(std::streambuf *replacement):
original_(std::cin.rdbuf(replacement)) {
std::cin.clear();
}
/**
* @brief Restore the original standard-input buffer and stream state.
*/
~cin_buffer_guard() noexcept {
try {
std::cin.rdbuf(original_);
std::cin.clear();
} catch (const std::ios_base::failure &stream_error) {
ADD_FAILURE() << "Failed to restore the standard-input buffer: " << stream_error.what();
}
}
cin_buffer_guard(const cin_buffer_guard &) = delete;
cin_buffer_guard &operator=(const cin_buffer_guard &) = delete;
private:
std::streambuf *original_; ///< Process input buffer restored at scope exit.
};
/**
* @brief Build a pairing session that is awaiting operator approval.
*
* @param unique_id Client-controlled GameStream unique identifier.
* @param device_name Client-reported device name.
* @param address Network address associated with the request.
* @return Pairing session ready for insertion into pending storage.
*/
pair_session_t pending_session(std::string unique_id, std::string device_name, std::string address) {
return {
.client = {
.uniqueID = std::move(unique_id),
},
.async_insert_pin = {
.salt = "ff5dc6eda99339a8a0793e216c4257c4",
.device_name = std::move(device_name),
.address = std::move(address),
},
};
}
/**
* @brief Fixture that isolates tests using global pending-pairing storage.
*/
class PairingSessionRegistryTest: public BaseTest {
protected:
void SetUp() override {
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
}
void TearDown() override {
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
}
};
/**
* @brief Exercise the production pairing dispatcher through a local HTTP server.
*/
class PairingHttpHandlerTest: public BaseTest {
protected:
/**
* @brief Start an isolated plain-HTTP pairing endpoint.
*/
void SetUp() override {
BaseTest::SetUp();
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
original_pin_stdin_ = config::sunshine.flags[config::flag::PIN_STDIN];
original_ping_timeout_ = config::stream.ping_timeout;
config::sunshine.flags[config::flag::PIN_STDIN] = false;
server_ = std::make_unique<SimpleWeb::Server<SimpleWeb::HTTP>>();
server_->config.port = 0;
server_->config.reuse_address = true;
server_->config.timeout_request = 5;
server_->config.timeout_content = 5;
server_->resource["^/pair$"]["GET"] = nvhttp::test_support::pair_http;
server_thread_ = std::jthread([this]() {
server_->start([this](const unsigned short assigned_port) {
port_ = assigned_port;
});
});
for (int attempt = 0; attempt < 100 && port_ == 0; ++attempt) {
std::this_thread::sleep_for(std::chrono::milliseconds {10});
}
ASSERT_NE(port_, 0) << "Pairing test server failed to start";
client_ = std::make_unique<SimpleWeb::Client<SimpleWeb::HTTP>>(std::format("localhost:{}", port_.load()));
client_->config.timeout = 5;
}
/**
* @brief Stop the endpoint and restore pairing configuration.
*/
void TearDown() override {
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
if (server_) {
server_->stop();
}
if (server_thread_.joinable()) {
server_thread_.join();
}
client_.reset();
server_.reset();
config::sunshine.flags[config::flag::PIN_STDIN] = original_pin_stdin_;
config::stream.ping_timeout = original_ping_timeout_;
BaseTest::TearDown();
}
/**
* @brief Build the query for the initial certificate phase.
*
* @param unique_id Client pairing identifier.
* @param salt Hexadecimal PIN salt.
* @return Request target for the local pairing endpoint.
*/
static std::string server_certificate_target(const std::string_view unique_id, const std::string_view salt = "ff5dc6eda99339a8a0793e216c4257c4") {
return std::format(
"/pair?uniqueid={}&phrase=getservercert&clientcert={}&salt={}&devicename=TestClient",
unique_id,
util::hex_vec(PUBLIC_CERT, true),
salt
);
}
/**
* @brief Read the response body from the local pairing endpoint.
*
* @param target Request target including query parameters.
* @return XML response body.
*/
std::string request(const std::string_view target) {
return client_->request("GET", std::string {target})->content.string();
}
/**
* @brief Wait for a request to enter the pending pairing registry.
*
* @return The operator-facing pairing identifier, or an empty string on timeout.
*/
static std::string wait_for_pending_pairing() {
for (int attempt = 0; attempt < 100; ++attempt) {
if (const auto pending = get_pending_pairings(); !pending.empty()) {
return pending.front().id;
}
std::this_thread::sleep_for(std::chrono::milliseconds {10});
}
return {};
}
private:
std::unique_ptr<SimpleWeb::Server<SimpleWeb::HTTP>> server_; ///< Local pairing HTTP server.
std::unique_ptr<SimpleWeb::Client<SimpleWeb::HTTP>> client_; ///< Client connected to the local server.
std::jthread server_thread_; ///< Thread running the local server event loop.
std::atomic<unsigned short> port_ {0}; ///< Ephemeral port assigned to the local server.
bool original_pin_stdin_; ///< Console-PIN flag restored after each test.
std::chrono::milliseconds original_ping_timeout_; ///< Configured client timeout restored after each test.
};
} // namespace
TEST_F(PairingHttpHandlerTest, ImmediateRequestsReturnExpectedStatus) {
EXPECT_NE(request("/pair"sv).find("status_code=\"400\""), std::string::npos);
EXPECT_NE(request("/pair?uniqueid=challenge&phrase=pairchallenge"sv).find("status_code=\"200\""), std::string::npos);
EXPECT_NE(request("/pair?uniqueid=missing"sv).find("Invalid uniqueid"), std::string::npos);
std::string pairing_id;
ASSERT_EQ(insert_pair_session(pending_session("out-of-order", "Client", "192.0.2.10"), pairing_id), pair_session_insert_e::ADDED);
EXPECT_NE(request("/pair?uniqueid=out-of-order&clientchallenge=00"sv).find("Out of order"), std::string::npos);
}
TEST_F(PairingHttpHandlerTest, WebApprovalRequestRemainsPendingUntilCancelled) {
std::packaged_task<std::string()> request_task {[this]() {
return request(server_certificate_target("pending"));
}};
auto response = request_task.get_future();
std::jthread request_thread {std::move(request_task)};
const auto pairing_id = wait_for_pending_pairing();
ASSERT_FALSE(pairing_id.empty());
EXPECT_TRUE(cancel_pairing(pairing_id));
EXPECT_NE(response.get().find("cancelled by operator"), std::string::npos);
}
TEST_F(PairingHttpHandlerTest, PinReturnsCompletedHandshakeResult) {
for (const bool expected_result : {false, true}) {
const auto unique_id = expected_result ? "successful-result"sv : "failed-result"sv;
std::packaged_task<std::string()> request_task {[this, unique_id]() {
return request(server_certificate_target(unique_id));
}};
auto client_response = request_task.get_future();
std::jthread request_thread {std::move(request_task)};
const auto pairing_id = wait_for_pending_pairing();
ASSERT_FALSE(pairing_id.empty());
config::stream.ping_timeout = std::chrono::seconds {2};
std::packaged_task<bool()> pin_task {[&pairing_id]() {
return pin(pairing_id, "5338", "Test client");
}};
auto pin_result = pin_task.get_future();
std::jthread pin_thread {std::move(pin_task)};
EXPECT_NE(client_response.get().find("status_code=\"200\""), std::string::npos);
EXPECT_TRUE(nvhttp::test_support::complete_pairing(pairing_id, expected_result));
EXPECT_EQ(pin_result.get(), expected_result);
}
}
TEST_F(PairingHttpHandlerTest, PinReturnsFalseWhenClientDoesNotCompleteHandshake) {
std::packaged_task<std::string()> request_task {[this]() {
return request(server_certificate_target("wrong-pin"));
}};
auto client_response = request_task.get_future();
std::jthread request_thread {std::move(request_task)};
const auto pairing_id = wait_for_pending_pairing();
ASSERT_FALSE(pairing_id.empty());
config::stream.ping_timeout = std::chrono::milliseconds {50};
EXPECT_FALSE(pin(pairing_id, "0000", "Test client"));
EXPECT_NE(client_response.get().find("status_code=\"200\""), std::string::npos);
EXPECT_TRUE(get_pending_pairings().empty());
}
TEST_F(PairingHttpHandlerTest, DuplicateAndCapacityErrorsReturnImmediately) {
std::string pairing_id;
ASSERT_EQ(insert_pair_session(pending_session("duplicate", "Original", "192.0.2.10"), pairing_id), pair_session_insert_e::ADDED);
EXPECT_NE(request(server_certificate_target("duplicate")).find("status_code=\"409\""), std::string::npos);
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
for (std::size_t index = 0; index < MAX_PENDING_PAIRING_SESSIONS; ++index) {
ASSERT_EQ(
insert_pair_session(pending_session(std::format("full-{}", index), "Client", "192.0.2.10"), pairing_id),
pair_session_insert_e::ADDED
);
}
EXPECT_NE(request(server_certificate_target("overflow")).find("status_code=\"503\""), std::string::npos);
}
TEST_F(PairingHttpHandlerTest, ConsolePinCompletesOrRejectsTheCertificatePhase) {
setup(PRIVATE_KEY, PUBLIC_CERT);
config::sunshine.flags[config::flag::PIN_STDIN] = true;
std::istringstream input {"5338\n0000\n"};
const cin_buffer_guard input_guard {input.rdbuf()};
EXPECT_NE(request(server_certificate_target("console-success")).find("status_code=\"200\""), std::string::npos);
EXPECT_NE(request(server_certificate_target("console-failure", "00")).find("status_code=\"400\""), std::string::npos);
}
TEST_F(PairingHttpHandlerTest, ConsolePinReportsSessionExpiration) {
config::sunshine.flags[config::flag::PIN_STDIN] = true;
gated_input_buffer input;
const cin_buffer_guard input_guard {&input};
std::packaged_task<std::string()> request_task {[this]() {
return request(server_certificate_target("expired"));
}};
auto response = request_task.get_future();
std::jthread request_thread {std::move(request_task)};
ASSERT_FALSE(wait_for_pending_pairing().empty());
expire_pair_sessions(std::chrono::steady_clock::time_point::max());
input.release();
EXPECT_NE(response.get().find("status_code=\"408\""), std::string::npos);
}
TEST_F(PairingSessionRegistryTest, PinApprovalTargetsExplicitPairingId) {
std::string legitimate_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("legitimate", "Living Room", "192.0.2.10"), legitimate_pairing_id),
pair_session_insert_e::ADDED
);
std::string attacker_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("attacker", "Living Room", "192.0.2.20"), attacker_pairing_id),
pair_session_insert_e::ADDED
);
ASSERT_NE(legitimate_pairing_id, attacker_pairing_id);
// Unit-test sessions have no parked HTTP response, so pin() returns false after
// consuming the explicitly selected session. The competing request must remain untouched.
EXPECT_FALSE(pin(legitimate_pairing_id, "1234", "Approved client"));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, attacker_pairing_id);
EXPECT_EQ(pending.front().address, "192.0.2.20");
}
TEST_F(PairingSessionRegistryTest, InvalidApprovalCannotConsumePendingSession) {
std::string pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("legitimate", "Laptop", "192.0.2.10"), pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_FALSE(pin("unknown-approval", "1234", "Laptop"));
EXPECT_FALSE(pin(pairing_id, "123", "Laptop"));
EXPECT_FALSE(pin(pairing_id, "12a4", "Laptop"));
EXPECT_FALSE(pin(pairing_id, "1234", ""));
EXPECT_FALSE(pin(pairing_id, "1234", std::string(MAX_PAIRING_CLIENT_NAME_SIZE + 1, 'a')));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, pairing_id);
}
TEST_F(PairingSessionRegistryTest, RestApiPairingFieldsAreValidatedServerSide) {
EXPECT_TRUE(is_valid_pairing_id(std::string(PAIRING_ID_SIZE, 'a')));
EXPECT_TRUE(is_valid_pairing_id("0123456789ABCDEF0123456789abcdef"));
EXPECT_FALSE(is_valid_pairing_id(std::string(PAIRING_ID_SIZE - 1, 'a')));
EXPECT_FALSE(is_valid_pairing_id(std::string(PAIRING_ID_SIZE, 'g')));
EXPECT_TRUE(is_valid_pairing_pin("0000"));
EXPECT_TRUE(is_valid_pairing_pin("9999"));
EXPECT_FALSE(is_valid_pairing_pin("123"));
EXPECT_FALSE(is_valid_pairing_pin("12a4"));
EXPECT_TRUE(is_valid_pairing_name("Client"));
EXPECT_TRUE(is_valid_pairing_name(std::string(MAX_PAIRING_CLIENT_NAME_SIZE, 'a')));
EXPECT_FALSE(is_valid_pairing_name(""));
EXPECT_FALSE(is_valid_pairing_name(std::string(MAX_PAIRING_CLIENT_NAME_SIZE + 1, 'a')));
}
TEST_F(PairingSessionRegistryTest, DuplicateUniqueIdIsRejectedWithoutReplacingOriginal) {
std::string original_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("duplicate", "Original", "192.0.2.10"), original_pairing_id),
pair_session_insert_e::ADDED
);
std::string rejected_pairing_id;
EXPECT_EQ(
insert_pair_session(pending_session("duplicate", "Replacement", "192.0.2.20"), rejected_pairing_id),
pair_session_insert_e::ALREADY_EXISTS
);
EXPECT_TRUE(rejected_pairing_id.empty());
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, original_pairing_id);
EXPECT_EQ(pending.front().name, "Original");
}
TEST_F(PairingSessionRegistryTest, PendingSessionStorageIsBounded) {
for (std::size_t index = 0; index < MAX_PENDING_PAIRING_SESSIONS; ++index) {
std::string pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session(std::format("client-{}", index), "Client", "192.0.2.10"), pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_FALSE(pairing_id.empty());
}
std::string overflow_pairing_id;
EXPECT_EQ(
insert_pair_session(pending_session("overflow", "Overflow", "192.0.2.20"), overflow_pairing_id),
pair_session_insert_e::FULL
);
EXPECT_EQ(get_pending_pairings().size(), MAX_PENDING_PAIRING_SESSIONS);
}
TEST_F(PairingSessionRegistryTest, ExpiredApprovalCannotConsumeLaterSession) {
const auto expiration_check = std::chrono::steady_clock::now() + PAIRING_SESSION_TIMEOUT + std::chrono::seconds {1};
std::string expired_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("expired", "Old client", "192.0.2.10"), expired_pairing_id),
pair_session_insert_e::ADDED
);
expire_pair_sessions(expiration_check);
EXPECT_TRUE(get_pending_pairings().empty());
std::string current_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("current", "Current client", "192.0.2.20"), current_pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_FALSE(pin(expired_pairing_id, "1234", "Old client"));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, current_pairing_id);
}
TEST_F(PairingSessionRegistryTest, CancellationRemovesOnlySelectedPendingSession) {
std::string first_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("first", "First", "192.0.2.10"), first_pairing_id),
pair_session_insert_e::ADDED
);
std::string second_pairing_id;
ASSERT_EQ(
insert_pair_session(pending_session("second", "Second", "192.0.2.20"), second_pairing_id),
pair_session_insert_e::ADDED
);
EXPECT_TRUE(cancel_pairing(first_pairing_id));
EXPECT_FALSE(cancel_pairing(first_pairing_id));
const auto pending = get_pending_pairings();
ASSERT_EQ(pending.size(), 1);
EXPECT_EQ(pending.front().id, second_pairing_id);
}
TEST_F(PairingSessionRegistryTest, RemoveSessionErasesItsUniqueId) {
auto session = pending_session("removable", "Client", "192.0.2.10");
pair_session_t session_key;
session_key.client.uniqueID = session.client.uniqueID;
std::string pairing_id;
ASSERT_EQ(insert_pair_session(std::move(session), pairing_id), pair_session_insert_e::ADDED);
remove_session(session_key);
EXPECT_TRUE(get_pending_pairings().empty());
}
TEST_F(PairingSessionRegistryTest, ConcurrentInsertionAndCancellationLeaveConsistentState) {
constexpr std::size_t session_count = 16;
std::atomic<std::size_t> successful_cancellations {0};
std::vector<std::jthread> workers;
workers.reserve(session_count);
for (std::size_t index = 0; index < session_count; ++index) {
workers.emplace_back([index, &successful_cancellations]() {
std::string pairing_id;
if (insert_pair_session(pending_session(std::format("concurrent-{}", index), "Client", "192.0.2.10"), pairing_id) == pair_session_insert_e::ADDED && cancel_pairing(pairing_id)) {
++successful_cancellations;
}
});
}
workers.clear();
EXPECT_EQ(successful_cancellations, session_count);
EXPECT_TRUE(get_pending_pairings().empty());
}