Files
obs-streamer-tools-plugin/core/tests/loopback_server.h
T
shadowdaoandClaude Sonnet 5 bf33966a4e 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
2026-09-06 21:28:55 -07:00

247 lines
8.0 KiB
C++

/*
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