Add the streamer-tools API client, with a real HTTP backend per platform
Implements the two read-key-scoped calls in apps/server/src/obs/plugin.routes.ts: GET /api/obs/:slug/slots and POST /api/obs/:slug/token. Three pieces, all in core/ with no OBS dependency: - stplugin::json -- a small, strict JSON reader. Hand-rolled rather than vendoring nlohmann because the only JSON this plugin ever sees is two fixed-shape responses from its own server, and the parser has to build on three platforms with no package-manager step in CI. It never throws, bounds its recursion (kMaxDepth=32) so a hostile response cannot overflow the stack inside OBS, rejects trailing garbage, and returns the caller's fallback for wrong-typed access instead of aborting. - stplugin::HttpClient -- a two-method injectable interface, with libcurl behind it on Linux/macOS and WinHTTP on Windows. WinHTTP rather than curl on Windows because it ships with the OS and does TLS through SChannel: the self-hosted winvm-builder runner has no package manager, and per the scaffold README does not even have cmake preinstalled. Both backends cap the response body at 4 MiB, keep TLS verification on (the read key is a credential), and honour a whole-request timeout. - stplugin::ApiClient -- maps the responses onto an ApiStatus enum that distinguishes NotFound (404), Unavailable (503), NetworkError, MalformedResponse and InvalidConfig. It deliberately does not claim to know whether a 404 was a wrong key or an unknown slug, because the server deliberately does not say. Server URLs are normalised the way an operator actually pastes them, defaulting to https so the read key is never sent in the clear by accident, and redactedUrl() exists so a URL can be logged without the key. Tests (279 checks across two new suites) run at two levels: a fake HttpClient covering every response and error branch, and a real loopback HTTP server on 127.0.0.1 driving the actual platform backend -- so libcurl on Linux/macOS and WinHTTP on Windows are each exercised in CI rather than assumed. The loopback cases deliberately include the ones that must not hang OBS: a truncated JSON body, a connection accepted and closed without a reply, non-HTTP garbage, a dead port, and a stalled server that has to be cut off by the client's own timeout. Verified locally on Ubuntu 24.04: ctest --test-dir build --output-on-failure -> 4/4 passed test_json: 158 checks passed test_api_client: 121 checks passed Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RL8abRmgFXkVASHkkqiJbE
This commit is contained in:
+21
-11
@@ -1,18 +1,28 @@
|
||||
add_executable(stplugin_core_tests
|
||||
test_core.cpp
|
||||
)
|
||||
target_link_libraries(stplugin_core_tests PRIVATE stplugin_core)
|
||||
add_test(NAME stplugin_core_tests COMMAND stplugin_core_tests)
|
||||
# Dependency-free CTest targets (see test_util.h for why there is no gtest).
|
||||
|
||||
function(stplugin_add_test name)
|
||||
add_executable(${name} ${name}.cpp)
|
||||
target_link_libraries(${name} PRIVATE stplugin_core)
|
||||
target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
if(WIN32)
|
||||
# loopback_server.h needs Winsock for the real-backend tests.
|
||||
target_link_libraries(${name} PRIVATE ws2_32)
|
||||
endif()
|
||||
add_test(NAME ${name} COMMAND ${name})
|
||||
# Nothing here should ever take a minute; a hang is a failure, not a
|
||||
# reason for CI to sit for its default 1500s.
|
||||
set_tests_properties(${name} PROPERTIES TIMEOUT 120)
|
||||
endfunction()
|
||||
|
||||
stplugin_add_test(test_core)
|
||||
stplugin_add_test(test_json)
|
||||
stplugin_add_test(test_api_client)
|
||||
|
||||
# Smoke test for the LiveKit SDK link: initialize()/shutdown() must succeed
|
||||
# in-process. This is the cheapest possible proof that LiveKit::livekit is
|
||||
# not just linked but loadable and callable (it dlopen-chains into
|
||||
# liblivekit_ffi, which is where a broken RPATH would show up).
|
||||
add_executable(stplugin_livekit_smoke
|
||||
test_livekit_smoke.cpp
|
||||
)
|
||||
target_link_libraries(stplugin_livekit_smoke PRIVATE stplugin_core)
|
||||
target_compile_definitions(stplugin_livekit_smoke PRIVATE
|
||||
stplugin_add_test(test_livekit_smoke)
|
||||
target_compile_definitions(test_livekit_smoke PRIVATE
|
||||
STPLUGIN_EXPECTED_LIVEKIT_VERSION="${LIVEKIT_SDK_VERSION_RESOLVED}"
|
||||
)
|
||||
add_test(NAME stplugin_livekit_smoke COMMAND stplugin_livekit_smoke)
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin - minimal loopback HTTP server for 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/>
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
// A single-threaded, one-request-at-a-time HTTP/1.1 server on 127.0.0.1, used
|
||||
// to exercise the *real* platform HTTP backend (libcurl on Linux/macOS,
|
||||
// WinHTTP on Windows) rather than only a fake. The handler returns raw bytes,
|
||||
// so tests can serve deliberately malformed responses and half-closed
|
||||
// connections -- the cases that must not hang or crash OBS.
|
||||
//
|
||||
// Plain HTTP only: a TLS listener would need a certificate and would test
|
||||
// libcurl/SChannel rather than this plugin.
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
using st_socket_t = SOCKET;
|
||||
#define ST_INVALID_SOCKET INVALID_SOCKET
|
||||
#define ST_CLOSE_SOCKET closesocket
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
using st_socket_t = int;
|
||||
#define ST_INVALID_SOCKET (-1)
|
||||
#define ST_CLOSE_SOCKET ::close
|
||||
#endif
|
||||
|
||||
namespace sttest {
|
||||
|
||||
/// Returns raw response bytes for a received raw request. Returning an empty
|
||||
/// string means "close the connection without replying".
|
||||
using LoopbackHandler = std::function<std::string(const std::string &request)>;
|
||||
|
||||
class LoopbackServer {
|
||||
public:
|
||||
explicit LoopbackServer(LoopbackHandler handler) : handler_(std::move(handler))
|
||||
{
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa;
|
||||
WSAStartup(MAKEWORD(2, 2), &wsa);
|
||||
#endif
|
||||
listen_ = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (listen_ == ST_INVALID_SOCKET)
|
||||
return;
|
||||
|
||||
int reuse = 1;
|
||||
::setsockopt(listen_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast<const char *>(&reuse), sizeof(reuse));
|
||||
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
addr.sin_port = 0; // let the OS pick a free port
|
||||
if (::bind(listen_, reinterpret_cast<sockaddr *>(&addr), sizeof(addr)) != 0) {
|
||||
ST_CLOSE_SOCKET(listen_);
|
||||
listen_ = ST_INVALID_SOCKET;
|
||||
return;
|
||||
}
|
||||
if (::listen(listen_, 4) != 0) {
|
||||
ST_CLOSE_SOCKET(listen_);
|
||||
listen_ = ST_INVALID_SOCKET;
|
||||
return;
|
||||
}
|
||||
|
||||
sockaddr_in bound{};
|
||||
#ifdef _WIN32
|
||||
int len = sizeof(bound);
|
||||
#else
|
||||
socklen_t len = sizeof(bound);
|
||||
#endif
|
||||
if (::getsockname(listen_, reinterpret_cast<sockaddr *>(&bound), &len) != 0) {
|
||||
ST_CLOSE_SOCKET(listen_);
|
||||
listen_ = ST_INVALID_SOCKET;
|
||||
return;
|
||||
}
|
||||
port_ = ntohs(bound.sin_port);
|
||||
|
||||
thread_ = std::thread([this] { run(); });
|
||||
}
|
||||
|
||||
~LoopbackServer()
|
||||
{
|
||||
stop_.store(true);
|
||||
if (thread_.joinable())
|
||||
thread_.join();
|
||||
if (listen_ != ST_INVALID_SOCKET)
|
||||
ST_CLOSE_SOCKET(listen_);
|
||||
#ifdef _WIN32
|
||||
WSACleanup();
|
||||
#endif
|
||||
}
|
||||
|
||||
LoopbackServer(const LoopbackServer &) = delete;
|
||||
LoopbackServer &operator=(const LoopbackServer &) = delete;
|
||||
|
||||
bool valid() const { return listen_ != ST_INVALID_SOCKET; }
|
||||
int port() const { return port_; }
|
||||
std::string baseUrl() const { return "http://127.0.0.1:" + std::to_string(port_); }
|
||||
int requestCount() const { return requests_.load(); }
|
||||
|
||||
/// The most recent raw request, for asserting on method/path/body.
|
||||
std::string lastRequest() const
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mutex_);
|
||||
return last_request_;
|
||||
}
|
||||
|
||||
private:
|
||||
void run()
|
||||
{
|
||||
while (!stop_.load()) {
|
||||
// select() with a short timeout rather than a blocking accept(),
|
||||
// so the destructor's stop flag is honoured promptly on every
|
||||
// platform (closing a socket another thread is blocked in
|
||||
// accept() on is not portable).
|
||||
fd_set readable;
|
||||
FD_ZERO(&readable);
|
||||
FD_SET(listen_, &readable);
|
||||
timeval tv{};
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 50000; // 50ms
|
||||
const int ready = ::select(static_cast<int>(listen_) + 1, &readable, nullptr, nullptr, &tv);
|
||||
if (ready <= 0)
|
||||
continue;
|
||||
|
||||
st_socket_t client = ::accept(listen_, nullptr, nullptr);
|
||||
if (client == ST_INVALID_SOCKET)
|
||||
continue;
|
||||
|
||||
const std::string request = readRequest(client);
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(mutex_);
|
||||
last_request_ = request;
|
||||
}
|
||||
requests_.fetch_add(1);
|
||||
|
||||
const std::string response = handler_ ? handler_(request) : std::string();
|
||||
if (!response.empty())
|
||||
sendAll(client, response);
|
||||
ST_CLOSE_SOCKET(client);
|
||||
}
|
||||
}
|
||||
|
||||
static std::string readRequest(st_socket_t client)
|
||||
{
|
||||
std::string data;
|
||||
char buffer[4096];
|
||||
std::size_t header_end = std::string::npos;
|
||||
long content_length = 0;
|
||||
|
||||
for (;;) {
|
||||
#ifdef _WIN32
|
||||
const int n = ::recv(client, buffer, static_cast<int>(sizeof(buffer)), 0);
|
||||
#else
|
||||
const ssize_t n = ::recv(client, buffer, sizeof(buffer), 0);
|
||||
#endif
|
||||
if (n <= 0)
|
||||
break;
|
||||
data.append(buffer, static_cast<std::size_t>(n));
|
||||
|
||||
if (header_end == std::string::npos) {
|
||||
header_end = data.find("\r\n\r\n");
|
||||
if (header_end != std::string::npos)
|
||||
content_length = parseContentLength(data.substr(0, header_end));
|
||||
}
|
||||
if (header_end != std::string::npos &&
|
||||
data.size() >= header_end + 4 + static_cast<std::size_t>(content_length))
|
||||
break;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static long parseContentLength(const std::string &headers)
|
||||
{
|
||||
std::string lower;
|
||||
lower.reserve(headers.size());
|
||||
for (char c : headers)
|
||||
lower.push_back(static_cast<char>(c >= 'A' && c <= 'Z' ? c + 32 : c));
|
||||
const std::size_t at = lower.find("content-length:");
|
||||
if (at == std::string::npos)
|
||||
return 0;
|
||||
return std::strtol(headers.c_str() + at + 15, nullptr, 10);
|
||||
}
|
||||
|
||||
static void sendAll(st_socket_t client, const std::string &data)
|
||||
{
|
||||
std::size_t sent = 0;
|
||||
while (sent < data.size()) {
|
||||
#ifdef _WIN32
|
||||
const int n = ::send(client, data.data() + sent, static_cast<int>(data.size() - sent), 0);
|
||||
#else
|
||||
const ssize_t n = ::send(client, data.data() + sent, data.size() - sent, 0);
|
||||
#endif
|
||||
if (n <= 0)
|
||||
return;
|
||||
sent += static_cast<std::size_t>(n);
|
||||
}
|
||||
}
|
||||
|
||||
LoopbackHandler handler_;
|
||||
st_socket_t listen_ = ST_INVALID_SOCKET;
|
||||
int port_ = 0;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::atomic<int> requests_{0};
|
||||
mutable std::mutex mutex_;
|
||||
std::string last_request_;
|
||||
};
|
||||
|
||||
/// Build a well-formed HTTP/1.1 response with an explicit Content-Length and
|
||||
/// Connection: close, so the client never waits for keep-alive reuse.
|
||||
inline std::string httpResponse(int status, const std::string &reason, const std::string &body,
|
||||
const std::string &content_type = "application/json")
|
||||
{
|
||||
return "HTTP/1.1 " + std::to_string(status) + " " + reason + "\r\n" + "Content-Type: " + content_type +
|
||||
"\r\n" + "Content-Length: " + std::to_string(body.size()) + "\r\n" + "Connection: close\r\n\r\n" +
|
||||
body;
|
||||
}
|
||||
|
||||
} // namespace sttest
|
||||
@@ -0,0 +1,434 @@
|
||||
/*
|
||||
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 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();
|
||||
testRequestShape();
|
||||
testSlotsHappyPath();
|
||||
testSlotsEdgeCases();
|
||||
testTokenHappyPath();
|
||||
testHttpErrorStatuses();
|
||||
testMalformedSuccessBodies();
|
||||
testNetworkErrorAndInvalidConfig();
|
||||
testPlatformBackendAgainstLoopback();
|
||||
testPlatformBackendTimeout();
|
||||
return st_test_report("api_client");
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
streamer-tools OBS Camera Plugin - JSON reader 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/>
|
||||
*/
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "stplugin/json.h"
|
||||
#include "test_util.h"
|
||||
|
||||
using stplugin::json::Value;
|
||||
using stplugin::json::parse;
|
||||
|
||||
static void testRealResponses()
|
||||
{
|
||||
// The exact shape apps/server/src/obs/plugin.routes.ts returns.
|
||||
const Value slots = parse(
|
||||
R"({"slots":[{"identity":"cam1","displayName":"Alice","live":true},)"
|
||||
R"({"identity":"cam2","displayName":"Bob","live":false}]})");
|
||||
ST_ASSERT(slots.valid());
|
||||
ST_ASSERT(slots.isObject());
|
||||
ST_ASSERT(slots["slots"].isArray());
|
||||
ST_ASSERT_EQ(slots["slots"].size(), std::size_t(2));
|
||||
ST_ASSERT_EQ(slots["slots"].at(0)["identity"].asString(), std::string("cam1"));
|
||||
ST_ASSERT_EQ(slots["slots"].at(0)["displayName"].asString(), std::string("Alice"));
|
||||
ST_ASSERT_EQ(slots["slots"].at(0)["live"].asBool(), true);
|
||||
ST_ASSERT_EQ(slots["slots"].at(1)["live"].asBool(true), false);
|
||||
|
||||
const Value token = parse(
|
||||
R"({"lkToken":"eyJhbGciOiJIUzI1NiJ9.abc.def","wsUrl":"wss://streamers.example.com",)"
|
||||
R"("identity":"obs:main-room:Ab_1-cd2"})");
|
||||
ST_ASSERT_EQ(token["lkToken"].asString(), std::string("eyJhbGciOiJIUzI1NiJ9.abc.def"));
|
||||
ST_ASSERT_EQ(token["wsUrl"].asString(), std::string("wss://streamers.example.com"));
|
||||
ST_ASSERT_EQ(token["identity"].asString(), std::string("obs:main-room:Ab_1-cd2"));
|
||||
|
||||
const Value error = parse(R"({"error":"not found"})");
|
||||
ST_ASSERT_EQ(error["error"].asString(), std::string("not found"));
|
||||
}
|
||||
|
||||
static void testScalarsAndEscapes()
|
||||
{
|
||||
ST_ASSERT(parse("null").isNull());
|
||||
ST_ASSERT_EQ(parse("true").asBool(), true);
|
||||
ST_ASSERT_EQ(parse("false").asBool(true), false);
|
||||
ST_ASSERT_EQ(parse("0").asNumber(), 0.0);
|
||||
ST_ASSERT_EQ(parse("-12").asNumber(), -12.0);
|
||||
ST_ASSERT_EQ(parse("1.5e2").asNumber(), 150.0);
|
||||
ST_ASSERT_EQ(parse("\"\"").asString("x"), std::string(""));
|
||||
ST_ASSERT_EQ(parse(R"("a\"b\\c\/d")").asString(), std::string("a\"b\\c/d"));
|
||||
ST_ASSERT_EQ(parse(R"("\n\t\r\b\f")").asString(), std::string("\n\t\r\b\f"));
|
||||
|
||||
// \u escapes, including a surrogate pair (an emoji in a display name is
|
||||
// entirely plausible and must not corrupt the dropdown).
|
||||
ST_ASSERT_EQ(parse(R"("\u0041")").asString(), std::string("A"));
|
||||
ST_ASSERT_EQ(parse(R"("caf\u00e9")").asString(), std::string("caf\xc3\xa9"));
|
||||
ST_ASSERT_EQ(parse(R"("\ud83d\ude00")").asString(), std::string("\xf0\x9f\x98\x80"));
|
||||
|
||||
// Whitespace everywhere legal.
|
||||
ST_ASSERT_EQ(parse(" {\n \"a\" :\t[ 1 , 2 ]\r\n} ")["a"].size(), std::size_t(2));
|
||||
}
|
||||
|
||||
static void testMalformedIsRejectedNotCrashed()
|
||||
{
|
||||
const char *bad[] = {
|
||||
"",
|
||||
" ",
|
||||
"{",
|
||||
"}",
|
||||
"[",
|
||||
"[1,",
|
||||
"[1,]",
|
||||
"{\"a\"}",
|
||||
"{\"a\":}",
|
||||
"{\"a\":1,}",
|
||||
"{a:1}",
|
||||
"{'a':1}",
|
||||
"\"unterminated",
|
||||
"\"bad\\escape\"",
|
||||
"\"\\u00\"",
|
||||
"\"\\uZZZZ\"",
|
||||
"\"\\ud83d\"", // lone high surrogate
|
||||
"\"\\ude00\"", // lone low surrogate
|
||||
"01", // leading zero
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1e",
|
||||
"1e+",
|
||||
"tru",
|
||||
"nulll",
|
||||
"{}garbage", // trailing content
|
||||
"[1,2] [3]",
|
||||
"\"raw\ncontrol\"", // literal control char inside a string
|
||||
"\xff\xfe", // binary garbage, e.g. an HTML error page prefix
|
||||
"<!DOCTYPE html><html><body>502 Bad Gateway</body></html>",
|
||||
};
|
||||
for (const char *text : bad) {
|
||||
const Value v = parse(text);
|
||||
ST_ASSERT(!v.valid());
|
||||
// Accessors on an invalid value must still be safe and return the
|
||||
// caller's fallback.
|
||||
ST_ASSERT_EQ(v["anything"].asString("fallback"), std::string("fallback"));
|
||||
ST_ASSERT_EQ(v.at(0).asNumber(-1.0), -1.0);
|
||||
ST_ASSERT_EQ(v.size(), std::size_t(0));
|
||||
}
|
||||
}
|
||||
|
||||
static void testDepthLimit()
|
||||
{
|
||||
// Deep-but-legal nesting is rejected rather than recursed into, so a
|
||||
// hostile response cannot overflow the stack inside OBS.
|
||||
std::string deep;
|
||||
const int depth = stplugin::json::kMaxDepth + 50;
|
||||
for (int i = 0; i < depth; ++i)
|
||||
deep += "[";
|
||||
for (int i = 0; i < depth; ++i)
|
||||
deep += "]";
|
||||
ST_ASSERT(!parse(deep).valid());
|
||||
|
||||
// Just inside the limit still parses.
|
||||
std::string shallow;
|
||||
for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i)
|
||||
shallow += "[";
|
||||
shallow += "1";
|
||||
for (int i = 0; i < stplugin::json::kMaxDepth - 1; ++i)
|
||||
shallow += "]";
|
||||
ST_ASSERT(parse(shallow).valid());
|
||||
}
|
||||
|
||||
static void testWrongTypesFallBack()
|
||||
{
|
||||
const Value v = parse(R"({"n":5,"s":"x","b":true,"arr":[1],"obj":{}})");
|
||||
ST_ASSERT_EQ(v["n"].asString("fallback"), std::string("fallback"));
|
||||
ST_ASSERT_EQ(v["s"].asNumber(-1.0), -1.0);
|
||||
ST_ASSERT_EQ(v["s"].asBool(true), true);
|
||||
ST_ASSERT_EQ(v["missing"].asString("fallback"), std::string("fallback"));
|
||||
ST_ASSERT_EQ(v["arr"].at(5).asNumber(-1.0), -1.0);
|
||||
ST_ASSERT_EQ(v["obj"].at(0).asNumber(-1.0), -1.0);
|
||||
ST_ASSERT_EQ(v["n"]["deeper"].asString("fallback"), std::string("fallback"));
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
testRealResponses();
|
||||
testScalarsAndEscapes();
|
||||
testMalformedIsRejectedNotCrashed();
|
||||
testDepthLimit();
|
||||
testWrongTypesFallBack();
|
||||
return st_test_report("json");
|
||||
}
|
||||
Reference in New Issue
Block a user