/** * @file tests/unit/test_http_pairing.cpp * @brief Test src/nvhttp.cpp HTTP pairing process */ // test includes #include "../tests_common.h" // standard includes #include #include #include #include #include #include #include #include #include // lib includes #include #include // local includes #include #include using namespace nvhttp; struct pairing_input { std::shared_ptr 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> { /** * @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("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("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("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("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 { .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 {.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 {.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 { .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 {.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 {.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()}, 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 {.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("root.paired") == 1); serverchallengeresp(sess, tree, "test"); ASSERT_FALSE(tree.get("root.paired") == 1); clientpairingsecret(sess, tree, "test"); ASSERT_FALSE(tree.get("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("root.paired") == 1); // Calling it again should fail getservercert(sess, tree, "test"); ASSERT_FALSE(tree.get("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>(); 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>(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> server_; ///< Local pairing HTTP server. std::unique_ptr> client_; ///< Client connected to the local server. std::jthread server_thread_; ///< Thread running the local server event loop. std::atomic 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); } /** * @brief HTTP dispatch reaches every pairing phase and rejects an unknown phase. */ TEST_F(PairingHttpHandlerTest, DispatchesEachPhaseAndRejectsUnknownRequests) { for (const auto phase : {"clientchallenge", "serverchallengeresp", "clientpairingsecret", "unknown"}) { std::string pairing_id; ASSERT_EQ(insert_pair_session(pending_session(phase, "Client", "192.0.2.10"), pairing_id), pair_session_insert_e::ADDED); const auto response = request(std::format("/pair?uniqueid={}&{}=00", phase, phase)); EXPECT_NE(response.find("status_code=\"400\""), std::string::npos); const auto expected = std::string_view {phase} == "unknown" ? "Invalid pairing request" : "Out of order"; EXPECT_NE(response.find(expected), std::string::npos) << phase; EXPECT_TRUE(get_pending_pairings().empty()); } } TEST_F(PairingHttpHandlerTest, WebApprovalRequestRemainsPendingUntilCancelled) { std::packaged_task 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 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 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 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, RetriedGetservercertReplacesAbandonedHandshake) { // First attempt: start pairing and approve the PIN, leaving the session mid-handshake. std::packaged_task first_request_task {[this]() { return request(server_certificate_target("retry")); }}; auto first_response = first_request_task.get_future(); std::jthread first_request_thread {std::move(first_request_task)}; const auto first_pairing_id = wait_for_pending_pairing(); ASSERT_FALSE(first_pairing_id.empty()); config::stream.ping_timeout = std::chrono::seconds {10}; std::packaged_task pin_task {[&first_pairing_id]() { return pin(first_pairing_id, "5338", "Test client"); }}; auto pin_result = pin_task.get_future(); std::jthread pin_thread {std::move(pin_task)}; // PIN approval answers the held request and advances the session past the certificate // phase, so it no longer appears in the pending list. EXPECT_NE(first_response.get().find("status_code=\"200\""), std::string::npos); for (int attempt = 0; attempt < 100 && !get_pending_pairings().empty(); ++attempt) { std::this_thread::sleep_for(std::chrono::milliseconds {10}); } EXPECT_TRUE(get_pending_pairings().empty()); // A fresh getservercert for the same client replaces the abandoned handshake instead // of failing with 409 until the session expires. std::packaged_task retry_request_task {[this]() { return request(server_certificate_target("retry")); }}; auto retry_response = retry_request_task.get_future(); std::jthread retry_request_thread {std::move(retry_request_task)}; const auto second_pairing_id = wait_for_pending_pairing(); ASSERT_FALSE(second_pairing_id.empty()); EXPECT_NE(second_pairing_id, first_pairing_id); EXPECT_FALSE(pin_result.get()); // The evicted handshake's waiter is woken with failure. EXPECT_TRUE(cancel_pairing(second_pairing_id)); EXPECT_NE(retry_response.get().find("cancelled by operator"), 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 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 successful_cancellations {0}; std::vector 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()); }