/* streamer-tools OBS Camera Plugin - API client tests Copyright (C) 2026 CyberCoveLLC 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 */ // Two layers of coverage: // 1. a fake HttpClient, for response parsing and every error branch; // 2. a real loopback HTTP server driven through the *platform* backend // (libcurl or WinHTTP), so the backend itself is exercised in CI on all // three runners rather than assumed to work. #include #include #include #include #include #include #include "stplugin/api_client.h" #include "stplugin/http.h" #include "loopback_server.h" #include "test_util.h" using namespace stplugin; namespace { class FakeHttpClient : public HttpClient { public: HttpResponse next; HttpRequest last; int calls = 0; HttpResponse send(const HttpRequest &request) override { last = request; ++calls; return next; } }; ConnectionConfig testConfig() { return ConnectionConfig{"https://streamers.example.com", "main-room", "readkey123"}; } std::shared_ptr makeFake(long status, const std::string &body) { auto fake = std::make_shared(); fake->next.status = status; fake->next.body = body; return fake; } // --------------------------------------------------------------------------- // URL handling // --------------------------------------------------------------------------- void testNormalizeServerUrl() { ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com"), std::string("https://a.example.com")); ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com/"), std::string("https://a.example.com")); ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://a.example.com///"), std::string("https://a.example.com")); ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" https://a.example.com "), std::string("https://a.example.com")); // No scheme defaults to https, never http: the read key is a credential. ST_ASSERT_EQ(ApiClient::normalizeServerUrl("a.example.com"), std::string("https://a.example.com")); // An explicit http:// is honoured -- the test LXC is reachable that way. ST_ASSERT_EQ(ApiClient::normalizeServerUrl("http://192.168.1.175:3000"), std::string("http://192.168.1.175:3000")); ST_ASSERT_EQ(ApiClient::normalizeServerUrl(""), std::string("")); ST_ASSERT_EQ(ApiClient::normalizeServerUrl(" "), std::string("")); ST_ASSERT_EQ(ApiClient::normalizeServerUrl("https://"), std::string("")); ST_ASSERT_EQ(ApiClient::normalizeServerUrl("/"), std::string("")); } void testUrlEncodeAndRedaction() { ST_ASSERT_EQ(urlEncode("plain-slug_1.0~"), std::string("plain-slug_1.0~")); ST_ASSERT_EQ(urlEncode("a b"), std::string("a%20b")); ST_ASSERT_EQ(urlEncode("a/b?c=d&e"), std::string("a%2Fb%3Fc%3Dd%26e")); ST_ASSERT_EQ(urlEncode("k\xc3\xa9y"), std::string("k%C3%A9y")); ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret"), std::string("https://h/api/obs/r/slots?key=***")); ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/api/obs/r/slots?key=secret&x=1"), std::string("https://h/api/obs/r/slots?key=***&x=1")); ST_ASSERT_EQ(ApiClient::redactedUrl("https://h/nothing"), std::string("https://h/nothing")); } void testRedactSensitiveParams() { // The LiveKit log bridge's actual use case: a signaling URL embedded in // a free-form SDK log line, not a bare query string. ST_ASSERT_EQ(ApiClient::redactSensitiveParams( "connecting to wss://lk.example.com/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.abc.def&x=1"), std::string("connecting to wss://lk.example.com/rtc?access_token=&x=1")); // A value can be terminated by a quote or whitespace, not just '&', since // this scrubs arbitrary text rather than a URL/query string. ST_ASSERT_EQ(ApiClient::redactSensitiveParams("url=\"wss://h/rtc?access_token=secret\" state=connecting"), std::string("url=\"wss://h/rtc?access_token=\" state=connecting")); // "key=" is also scrubbed, matching redactedUrl's convention. ST_ASSERT_EQ(ApiClient::redactSensitiveParams("GET https://h/api/obs/r/slots?key=secret"), std::string("GET https://h/api/obs/r/slots?key=")); // Both params can appear in the same message, and each is independently // redacted. ST_ASSERT_EQ( ApiClient::redactSensitiveParams("a access_token=tok1 b key=tok2 c"), std::string("a access_token= b key= c")); // Text with neither parameter passes through unchanged. ST_ASSERT_EQ(ApiClient::redactSensitiveParams("livekit: participant joined"), std::string("livekit: participant joined")); // A value at the very end of the string is still bounded correctly. ST_ASSERT_EQ(ApiClient::redactSensitiveParams("token was access_token=trailing"), std::string("token was access_token=")); } void testRequestShape() { auto fake = makeFake(200, R"({"slots":[]})"); ApiClient client(fake); ConnectionConfig config = testConfig(); // Values that need encoding, and stray whitespace an operator would paste. config.room_slug = " main room "; config.read_key = " a+b/c "; (void)client.fetchSlots(config); ST_ASSERT_EQ(fake->last.method, std::string("GET")); ST_ASSERT_EQ(fake->last.url, std::string("https://streamers.example.com/api/obs/main%20room/slots?key=a%2Bb%2Fc")); auto fake2 = makeFake(200, R"({"lkToken":"t","wsUrl":"wss://x","identity":"obs:r:1"})"); ApiClient client2(fake2); (void)client2.requestToken(testConfig()); ST_ASSERT_EQ(fake2->last.method, std::string("POST")); ST_ASSERT_EQ(fake2->last.url, std::string("https://streamers.example.com/api/obs/main-room/token?key=readkey123")); ST_ASSERT_EQ(fake2->last.content_type, std::string("application/json")); } // --------------------------------------------------------------------------- // Response parsing // --------------------------------------------------------------------------- void testSlotsHappyPath() { auto fake = makeFake(200, R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)" R"({"identity":"cam2","displayName":"Bob","live":false}]})"); ApiClient client(fake); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.slots.size(), std::size_t(2)); ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); ST_ASSERT_EQ(result.slots[0].display_name, std::string("Alice")); ST_ASSERT_EQ(result.slots[0].live, true); ST_ASSERT_EQ(result.slots[1].live, false); } void testSlotsEdgeCases() { // Empty room: a valid answer, not an error. { ApiClient client(makeFake(200, R"({"slots":[]})")); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.slots.size(), std::size_t(0)); } // A missing/blank displayName falls back to the identity, matching what // the server itself does for a slot with no display_name. { ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1"},{"identity":"cam2","displayName":""}]})")); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.slots.size(), std::size_t(2)); ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1")); ST_ASSERT_EQ(result.slots[1].display_name, std::string("cam2")); ST_ASSERT_EQ(result.slots[0].live, false); // missing `live` is not live } // An entry with no identity is unusable and is dropped, not surfaced as a // dropdown row that could never connect. { ApiClient client(makeFake(200, R"({"slots":[{"displayName":"ghost"},{"identity":"cam1"}]})")); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); } // Wrong types where the shape is otherwise right: don't crash, don't // invent values. { ApiClient client(makeFake(200, R"({"slots":[{"identity":"cam1","displayName":42,"live":"yes"}]})")); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); ST_ASSERT_EQ(result.slots[0].display_name, std::string("cam1")); ST_ASSERT_EQ(result.slots[0].live, false); } } void testTokenHappyPath() { ApiClient client(makeFake(200, R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def",)" R"("wsUrl":"wss://streamers.example.com","identity":"obs:main-room:Ab_1"})")); const TokenResult result = client.requestToken(testConfig()); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.lk_token, std::string("eyJhbGciOiJIUzI1NiJ9.abc.def")); ST_ASSERT_EQ(result.ws_url, std::string("wss://streamers.example.com")); ST_ASSERT_EQ(result.identity, std::string("obs:main-room:Ab_1")); } void testHttpErrorStatuses() { // 404 -- a wrong read key and an unknown slug are deliberately // indistinguishable server-side, so the message must not claim to know. { ApiClient client(makeFake(404, R"({"error":"not found"})")); const SlotsResult slots = client.fetchSlots(testConfig()); ST_ASSERT(!slots.ok()); ST_ASSERT(slots.status == ApiStatus::NotFound); ST_ASSERT_EQ(slots.slots.size(), std::size_t(0)); const TokenResult token = client.requestToken(testConfig()); ST_ASSERT(token.status == ApiStatus::NotFound); ST_ASSERT_EQ(token.lk_token, std::string("")); } // 503 -- server reachable, LiveKit not configured. { ApiClient client(makeFake(503, R"({"error":"livekit not configured"})")); ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::Unavailable); ST_ASSERT(client.requestToken(testConfig()).status == ApiStatus::Unavailable); } // Anything else, e.g. a reverse proxy answering before the app does. { ApiClient client(makeFake(502, "502 Bad Gateway")); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.status == ApiStatus::HttpError); ST_ASSERT_EQ(result.message, std::string("HTTP 502")); } { ApiClient client(makeFake(401, "")); ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::HttpError); } } void testMalformedSuccessBodies() { // 200 with a body that is not the expected shape must be reported, not // silently treated as "no slots". const char *bad_slots[] = { "", "not json at all", "{}", R"({"slots":null})", R"({"slots":{}})", R"({"slots":"cam1"})", "[]", R"({"slots":[)", "login page", }; for (const char *body : bad_slots) { ApiClient client(makeFake(200, body)); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.status == ApiStatus::MalformedResponse); ST_ASSERT_EQ(result.slots.size(), std::size_t(0)); } const char *bad_token[] = { "", "{}", R"({"lkToken":""})", R"({"lkToken":"t"})", // no wsUrl R"({"wsUrl":"wss://x"})", // no token R"({"lkToken":123,"wsUrl":"wss://x"})", // wrong type R"({"lkToken":"t","wsUrl":""})", "[]", "\xff\xfe binary", }; for (const char *body : bad_token) { ApiClient client(makeFake(200, body)); const TokenResult result = client.requestToken(testConfig()); ST_ASSERT(result.status == ApiStatus::MalformedResponse); ST_ASSERT_EQ(result.lk_token, std::string("")); } } void testNetworkErrorAndInvalidConfig() { { auto fake = std::make_shared(); fake->next.network_error = "Could not resolve host"; ApiClient client(fake); const SlotsResult result = client.fetchSlots(testConfig()); ST_ASSERT(result.status == ApiStatus::NetworkError); ST_ASSERT_EQ(result.message, std::string("Could not resolve host")); } // An incomplete config must never reach the HTTP layer at all. { auto fake = makeFake(200, R"({"slots":[]})"); ApiClient client(fake); ST_ASSERT(client.fetchSlots(ConnectionConfig{"", "r", "k"}).status == ApiStatus::InvalidConfig); ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "", "k"}).status == ApiStatus::InvalidConfig); ST_ASSERT(client.fetchSlots(ConnectionConfig{"https://h", "r", ""}).status == ApiStatus::InvalidConfig); ST_ASSERT(client.requestToken(ConnectionConfig{"https://", "r", "k"}).status == ApiStatus::InvalidConfig); ST_ASSERT_EQ(fake->calls, 0); } // A null HTTP client is a programming error, not a crash. { ApiClient client(nullptr); ST_ASSERT(client.fetchSlots(testConfig()).status == ApiStatus::InvalidConfig); } } // --------------------------------------------------------------------------- // Real platform HTTP backend, against a real loopback socket // --------------------------------------------------------------------------- ConnectionConfig loopbackConfig(const sttest::LoopbackServer &server) { return ConnectionConfig{server.baseUrl(), "main-room", "readkey123"}; } void testPlatformBackendAgainstLoopback() { std::shared_ptr http(createPlatformHttpClient()); ST_ASSERT(http != nullptr); if (!http) return; ApiClient client(http); // 200 with real slots, and the request line/headers the server sees. { sttest::LoopbackServer server([](const std::string &) { return sttest::httpResponse(200, "OK", R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true}]})"); }); ST_ASSERT(server.valid()); const SlotsResult result = client.fetchSlots(loopbackConfig(server)); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.slots.size(), std::size_t(1)); ST_ASSERT_EQ(result.slots[0].identity, std::string("cam1")); ST_ASSERT(server.lastRequest().find("GET /api/obs/main-room/slots?key=readkey123") == 0); } // POST /token: verify the method and that a body is actually sent. { sttest::LoopbackServer server([](const std::string &) { return sttest::httpResponse(200, "OK", R"({"lkToken":"tok","wsUrl":"wss://lk.example","identity":"obs:r:1"})"); }); ST_ASSERT(server.valid()); const TokenResult result = client.requestToken(loopbackConfig(server)); ST_ASSERT(result.ok()); ST_ASSERT_EQ(result.lk_token, std::string("tok")); ST_ASSERT_EQ(result.ws_url, std::string("wss://lk.example")); ST_ASSERT(server.lastRequest().find("POST /api/obs/main-room/token?key=readkey123") == 0); } // 404 and 503 over a real socket. { sttest::LoopbackServer server([](const std::string &) { return sttest::httpResponse(404, "Not Found", R"({"error":"not found"})"); }); ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::NotFound); } { sttest::LoopbackServer server([](const std::string &) { return sttest::httpResponse(503, "Service Unavailable", R"({"error":"livekit not configured"})"); }); ST_ASSERT(client.requestToken(loopbackConfig(server)).status == ApiStatus::Unavailable); } // 200 with a truncated JSON body: must be MalformedResponse, not a hang // and not a crash. { sttest::LoopbackServer server([](const std::string &) { return sttest::httpResponse(200, "OK", R"({"slots":[{"identity":)"); }); ST_ASSERT(client.fetchSlots(loopbackConfig(server)).status == ApiStatus::MalformedResponse); } // A server that accepts the connection and closes without replying at // all. This is a network error, and it must come back promptly. { sttest::LoopbackServer server([](const std::string &) { return std::string(); }); const auto start = std::chrono::steady_clock::now(); const SlotsResult result = client.fetchSlots(loopbackConfig(server)); const auto elapsed = std::chrono::steady_clock::now() - start; ST_ASSERT(result.status == ApiStatus::NetworkError); ST_ASSERT(std::chrono::duration_cast(elapsed).count() < 15); } // Garbage that is not HTTP at all. { sttest::LoopbackServer server([](const std::string &) { return std::string("\x01\x02not http\r\n\r\n"); }); const SlotsResult result = client.fetchSlots(loopbackConfig(server)); ST_ASSERT(result.status == ApiStatus::NetworkError || result.status == ApiStatus::MalformedResponse || result.status == ApiStatus::HttpError); } // Nothing listening on the port at all: a clean NetworkError. { int dead_port = 0; { sttest::LoopbackServer server([](const std::string &) { return std::string(); }); dead_port = server.port(); } // server destroyed, port closed ConnectionConfig config{"http://127.0.0.1:" + std::to_string(dead_port), "main-room", "readkey123"}; ST_ASSERT(client.fetchSlots(config).status == ApiStatus::NetworkError); } } void testPlatformBackendTimeout() { // A server that accepts and then stalls. The plugin must give up on its // own timeout rather than blocking an OBS thread indefinitely. // // INSTRUMENTED (2026-09-09) while chasing an intermittent Windows-only // failure: on roughly 2 of 6 CI runs both assertions below fail together, // meaning the request waited out the full 5s stall and returned 200 -- // the timeout did not fire at all. Same failure seen on 2026-09-07 // (job 5834) and 2026-09-09 (job 5911), on identical code that passed on // other runs, so it is not a code change that caused it. // // The probe runs kProbes times and prints one line per attempt so a // single CI run yields a failure RATE and the WinHTTP error code, rather // than one bit. `ST_ASSERT` records and continues, so every attempt is // reported even when one fails. Remove the loop and this comment once the // mechanism is understood and fixed. constexpr int kProbes = 5; constexpr long long kStallMs = 5000; constexpr long kTimeoutMs = 700; int timed_out = 0; for (int i = 0; i < kProbes; ++i) { // A FRESH server per attempt, deliberately. `LoopbackServer` accepts // and handles one connection at a time on a single thread, so reusing // one server across attempts would leave attempts 2..n sitting in the // accept backlog -- a different scenario (never accepted) from the one // that fails on Windows (accepted, request read, then stalled). sttest::LoopbackServer server([kStallMs](const std::string &) { std::this_thread::sleep_for(std::chrono::milliseconds(kStallMs)); return sttest::httpResponse(200, "OK", R"({"slots":[]})"); }); ST_ASSERT(server.valid()); std::shared_ptr http(createPlatformHttpClient()); HttpRequest request; request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k"; request.timeout_ms = kTimeoutMs; const auto start = std::chrono::steady_clock::now(); const HttpResponse response = http->send(request); const auto elapsed = std::chrono::steady_clock::now() - start; const long long ms = std::chrono::duration_cast(elapsed).count(); // 4000 was the old bound, chosen when nothing bounded the wait. The // code now promises a hard ceiling of 2x the caller's budget // (RequestDeadline in http_winhttp.cpp), so assert THAT -- 1400ms // here, plus slack for a loaded runner. This is also the only signal // that survives a green run: CTest prints nothing on success, so if // WinHTTP's own erratic cancellation (measured at 1490-4506ms for // this same 700ms budget) were doing the work instead of the // watchdog, roughly half the attempts would land above this bound and // say so, instead of quietly passing under a 4s ceiling. constexpr long long kCeilingMs = 2500; const bool gave_up = !response.ok() && ms < kCeilingMs; if (gave_up) ++timed_out; // Always printed, pass or fail: elapsed time and the backend's own // error string (which carries GetLastError on Windows) are the // evidence. requests_seen separates "the client never reached the // server" (0) from "the server read the request and the client then // waited it out" (1). std::fprintf(stderr, " [timeout-probe %d/%d] elapsed=%lldms ok=%d status=%ld " "requests_seen=%d network_error='%s' -> %s\n", i + 1, kProbes, ms, response.ok() ? 1 : 0, response.status, server.requestCount(), response.network_error.c_str(), gave_up ? "gave up (expected)" : "WAITED OUT THE STALL"); ST_ASSERT(!response.ok()); ST_ASSERT(ms < kCeilingMs); } std::fprintf(stderr, " [timeout-probe] %d/%d attempts honoured the %ldms timeout\n", timed_out, kProbes, kTimeoutMs); } } // namespace int main() { testNormalizeServerUrl(); testUrlEncodeAndRedaction(); testRedactSensitiveParams(); testRequestShape(); testSlotsHappyPath(); testSlotsEdgeCases(); testTokenHappyPath(); testHttpErrorStatuses(); testMalformedSuccessBodies(); testNetworkErrorAndInvalidConfig(); testPlatformBackendAgainstLoopback(); testPlatformBackendTimeout(); return st_test_report("api_client"); }