Files
obs-streamer-tools-plugin/core/tests/test_api_client.cpp
T
shadowdaoandClaude Sonnet 5 f2a4932eea Fix review findings: stopping-flag race, unpinned SDK download, key-leak via redirect/logs
Code review findings from before merging feat/livekit-integration to main:

- I1: sourceDestroy set self->stopping outside self->mutex, then notified.
  The worker's condition-variable predicate reads `stopping` under that same
  mutex, so the store+notify could land between the worker's predicate check
  and it entering the wait, dropping the notification and leaving the worker
  asleep for its full backoff (up to 30s) with the OBS UI thread blocked in
  worker.join(). Now set under the lock, matching how `generation` is
  already mutated in applySettings.

- I3: the LiveKit SDK archive download in cmake/LiveKitSDK.cmake had no
  SHA256 pin wired up from the top-level CMakeLists.txt, unlike the obs-deps
  bootstrap right next to it. Added real SHA256 hashes -- computed by
  downloading each release archive and running sha256sum -- for every
  triple the pinned v1.10.1 release can resolve to (Linux x64/arm64, macOS
  x64/arm64, Windows x64), keyed by version+triple so a future version bump
  fails loudly (via message(WARNING)) instead of silently going unverified.
  Verified end-to-end locally: a deliberately wrong hash makes the configure
  step fail with a HASH mismatch error. Only Linux was also build-tested in
  this environment; macOS/Windows archives were downloaded and hashed but
  not build-tested here.

- I4: the curl HTTP backend followed up to 3 redirects while the read key
  travels as a URL query parameter, so a malicious/misconfigured redirect
  (including an HTTPS->HTTP downgrade, which curl doesn't refuse by default)
  could leak the key. This client only ever talks to two fixed, first-party
  endpoints, so redirects are disabled outright (CURLOPT_FOLLOWLOCATION 0),
  matching the WinHTTP backend's existing default behavior. Left
  normalizeServerUrl's explicit-http:// pass-through as-is with a comment,
  per review guidance.

- I5: ApiClient::redactedUrl was tested but never called. No current call
  site logs a request URL, so rather than inventing one, added a one-line
  comment marking it a deliberate guard rail for future logging.

- I7: the LiveKit SDK log bridge (livekitLogToObs) wrote SDK messages
  straight into the OBS log. LiveKit's signaling URL carries the access
  token as a query parameter; defensively scrub "access_token=" and "key="
  values before they ever reach obs_log. New ApiClient::redactSensitiveParams
  generalizes redactedUrl's redaction pattern to arbitrary text (not just a
  bare URL), with 6 new unit tests in test_api_client.cpp.

- I2: added a code comment on session.cpp's auto_subscribe=true noting the
  known, unaddressed bandwidth/CPU cost of pulling every participant's
  track in multi-camera rooms, and that per-publication unsubscribe is a
  future optimization. No behavior change (out of scope per review).

Verified: cmake configure + build + `ctest --test-dir build
--output-on-failure` all pass, 6/6 suites (test_api_client now 127 checks,
up from 121).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
2026-09-06 22:59:57 -07:00

468 lines
19 KiB
C++

/*
streamer-tools OBS Camera Plugin - API client tests
Copyright (C) 2026 CyberCoveLLC <jknapp85@gmail.com>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>
*/
// 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 <chrono>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#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<FakeHttpClient> makeFake(long status, const std::string &body)
{
auto fake = std::make_shared<FakeHttpClient>();
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=<redacted>&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=<redacted>\" 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=<redacted>"));
// 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=<redacted> b key=<redacted> 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=<redacted>"));
}
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, "<html>502 Bad Gateway</html>"));
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":[)",
"<!DOCTYPE html><html>login page</html>",
};
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<FakeHttpClient>();
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<HttpClient> 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<std::chrono::seconds>(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.
sttest::LoopbackServer server([](const std::string &) {
std::this_thread::sleep_for(std::chrono::seconds(5));
return sttest::httpResponse(200, "OK", R"({"slots":[]})");
});
ST_ASSERT(server.valid());
std::shared_ptr<HttpClient> http(createPlatformHttpClient());
HttpRequest request;
request.url = server.baseUrl() + "/api/obs/main-room/slots?key=k";
request.timeout_ms = 700;
const auto start = std::chrono::steady_clock::now();
const HttpResponse response = http->send(request);
const auto elapsed = std::chrono::steady_clock::now() - start;
ST_ASSERT(!response.ok());
ST_ASSERT(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count() < 4000);
}
} // namespace
int main()
{
testNormalizeServerUrl();
testUrlEncodeAndRedaction();
testRedactSensitiveParams();
testRequestShape();
testSlotsHappyPath();
testSlotsEdgeCases();
testTokenHappyPath();
testHttpErrorStatuses();
testMalformedSuccessBodies();
testNetworkErrorAndInvalidConfig();
testPlatformBackendAgainstLoopback();
testPlatformBackendTimeout();
return st_test_report("api_client");
}