Files
product-vamp/tests/test_health.cpp
T
khodowany da5640291a
ci / checks (push) Successful in 1h15m45s
Cleanupf
2026-09-23 23:54:19 -05:00

553 lines
25 KiB
C++

#include <boost/test/unit_test.hpp>
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <future>
#include <iterator>
#include <string>
#include <thread>
#include <utility>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/address_v6.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json.hpp>
#include <boost/system/error_code.hpp>
#include <openssl/sha.h>
#include <raylib.h>
#include <unistd.h>
#include <vamp_zero/app.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/health.hpp>
#include <vamp_zero/portal.hpp>
#include "catalogue_fixture.hpp"
namespace {
struct temporary_directory {
std::filesystem::path path;
temporary_directory() {
auto pattern = (std::filesystem::temp_directory_path() / "vamp-health-XXXXXX").string();
BOOST_REQUIRE(::mkdtemp(pattern.data()) != nullptr);
path = pattern;
}
~temporary_directory() {
auto ignored = std::error_code{};
std::filesystem::remove_all(path, ignored);
}
};
auto write_file(std::filesystem::path const &path, std::string const &bytes) -> void {
std::filesystem::create_directories(path.parent_path());
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
output << bytes;
output.close();
BOOST_REQUIRE(output.good());
}
auto read_file(std::filesystem::path const &path) -> std::string {
auto input = std::ifstream{path, std::ios::binary};
BOOST_REQUIRE(input.good());
return {std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
}
auto healthy_payload() -> boost::json::value {
return boost::json::object{{"device", "zero"}, {"status", "ready"}, {"product", "product-vamp"}};
}
auto health_router(boost::json::value payload) -> ligo::service_router {
auto router = ligo::service_router{};
router.add_route("GET", "/api/health", [payload = std::move(payload)](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<boost::json::value, modulus::error>> { co_return payload; });
return router;
}
auto ipv6_loopback_available(boost::unit_test::test_unit_id) -> boost::test_tools::assertion_result {
auto io = boost::asio::io_context{};
auto acceptor = boost::asio::ip::tcp::acceptor{io};
auto error = boost::system::error_code{};
acceptor.open(boost::asio::ip::tcp::v6(), error);
if (!error) {
acceptor.bind({boost::asio::ip::address_v6::loopback(), 0}, error);
}
// Other setup failures must still be exercised by the HTTP server test.
auto unavailable = error == boost::system::errc::address_family_not_supported || error == boost::system::errc::address_not_available || error == boost::system::errc::protocol_not_supported;
auto result = boost::test_tools::assertion_result{!unavailable};
if (unavailable) {
result.message() << "IPv6 loopback is unavailable: " << error.message();
}
return result;
}
struct http_service {
boost::asio::io_context io;
ligo::http::server server;
std::thread thread;
explicit http_service(ligo::service_router router, std::string host = "127.0.0.1")
: server{io, {.host = std::move(host)}, std::move(router), {.timeout = std::chrono::milliseconds{250}}} {
auto started = server.start();
BOOST_REQUIRE_MESSAGE(started, (started ? "" : started.error().describe()));
thread = std::thread{[this] { io.run(); }};
}
~http_service() {
server.stop();
io.stop();
thread.join();
}
auto config(std::string host = "127.0.0.1") const -> vamp_zero::config {
auto result = vamp_zero::config{};
result.control.endpoint.bind = host + ":" + std::to_string(server.selected_port());
result.control.endpoint.timeout = std::chrono::milliseconds{250};
result.control.zero_ip = "192.0.2.1";
result.control.endpoint.connect = "192.0.2.2:1";
return result;
}
};
auto unused_port() -> std::uint16_t {
auto io = boost::asio::io_context{};
auto acceptor = boost::asio::ip::tcp::acceptor{io, {boost::asio::ip::make_address("127.0.0.1"), 0}};
return acceptor.local_endpoint().port();
}
auto fixture_config(std::filesystem::path const &root) -> vamp_zero::config {
auto config = vamp_zero::config{};
config.runtime.assets_path = (root / "assets").string();
config.runtime.heartbeat_path = (root / "run" / "heartbeat.json").string();
config.database.path = (root / "db" / "vamp.db").string();
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
config.control.endpoint.timeout = std::chrono::milliseconds{250};
return config;
}
auto config_file(std::filesystem::path const &root, vamp_zero::config const &config) -> std::string {
auto json = boost::json::object{
{"modulus", modulus::to_json(config.modulus)},
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
{"database", {{"path", config.database.path}}},
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
{"audio", {{"enabled", true}, {"playback_enabled", true}, {"metronome_enabled", true}}},
};
auto path = root / "config.json";
write_file(path, boost::json::serialize(json));
return path.string();
}
auto fixture_page(std::int32_t width = 976, std::int32_t height = 484) -> std::string {
auto image = GenImageColor(width, height, WHITE);
auto size = std::int32_t{};
auto bytes = ExportImageToMemory(image, ".png", &size);
UnloadImage(image);
BOOST_REQUIRE(bytes != nullptr);
auto result = std::string{reinterpret_cast<std::char_t const *>(bytes), static_cast<std::size_t>(size)};
MemFree(bytes);
return result;
}
auto checksum(std::string const &bytes) -> std::string {
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
SHA256(reinterpret_cast<std::uchar_t const *>(bytes.data()), bytes.size(), digest.data());
auto result = std::string{};
constexpr auto digits = std::string_view{"0123456789abcdef"};
for (std::uchar_t byte : digest) {
result.push_back(digits[byte >> 4U]);
result.push_back(digits[byte & 15U]);
}
return result;
}
auto write_score(std::filesystem::path const &path, std::string part = "Lead") -> void {
auto score = boost::json::parse(R"({"schema_version":3,"music":{"metadata":{"title":"Fixture","composer":"Composer",
"key_signature":"C","time_signature":"4/4"},"staves":[{"name":"Lead","measures":[{"voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[]}})");
score.at("music").at("staves").as_array().front().as_object()["name"] = part;
auto &pages = score.at("presentation").at("pages").as_array();
for (auto index = std::uint32_t{1}; index <= 2U; ++index) {
auto relative = path.stem().string() + ".pages/" + std::to_string(index) + ".png";
auto bytes = fixture_page();
write_file(path.parent_path() / relative, bytes);
pages.push_back(boost::json::object{{"path", relative}, {"sha256", checksum(bytes)}});
}
write_file(path, boost::json::serialize(score));
}
auto make_assets(vamp_zero::config const &config) -> void {
auto root = std::filesystem::path{config.runtime.assets_path};
write_score(root / "songs" / "first.json");
write_score(root / "songs" / "second.json");
for (auto part_name : vamp_zero::practice_part_names()) {
auto name = std::string{part_name};
name.front() = static_cast<std::char_t>(name.front() - 'A' + 'a');
write_score(root / "practice" / "001" / (name + ".json"), std::string{part_name});
}
BOOST_REQUIRE(vamp_test::build_catalogue(root, config.database.path));
}
auto run_once(std::filesystem::path const &root, vamp_zero::config const &config) -> std::expected<void, modulus::error> {
return vamp_zero::run({.config_path = config_file(root, config), .exit_after_startup = true});
}
auto require_no_probe(vamp_zero::config const &config, std::bool_t heartbeat_exists = false) -> void {
auto directory = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
if (!std::filesystem::exists(directory)) {
return;
}
auto count = std::uint32_t{};
for (auto const &entry : std::filesystem::directory_iterator{directory}) {
BOOST_TEST(entry.path().filename().string() == "heartbeat.json");
++count;
}
BOOST_TEST(count == (heartbeat_exists ? 1U : 0U));
}
}
BOOST_AUTO_TEST_CASE(health_uses_configured_bind_and_normalizes_wildcards) {
auto service = http_service{health_router(healthy_payload())};
BOOST_TEST(vamp_zero::check_health(service.config()).has_value());
BOOST_TEST(vamp_zero::check_health(service.config("0.0.0.0")).has_value());
auto other = http_service{health_router(healthy_payload()), "127.0.0.2"};
BOOST_TEST(vamp_zero::check_health(other.config("127.0.0.2")).has_value());
}
BOOST_AUTO_TEST_CASE(health_normalizes_ipv6_wildcards, *boost::unit_test::precondition(ipv6_loopback_available)) {
auto ipv6 = http_service{health_router(healthy_payload()), "::1"};
BOOST_TEST(vamp_zero::check_health(ipv6.config("[::1]")).has_value());
BOOST_TEST(vamp_zero::check_health(ipv6.config("[::]")).has_value());
BOOST_TEST(vamp_zero::check_health(ipv6.config("[0:0:0:0:0:0:0:0]")).has_value());
}
BOOST_AUTO_TEST_CASE(health_rejects_wrong_device_product_status_and_payload_types) {
for (std::char_t const *field : {"product", "device", "status"}) {
for (auto value : {boost::json::value{"wrong"}, boost::json::value{42}, boost::json::value{}}) {
auto payload = healthy_payload();
payload.as_object()[field] = value;
auto service = http_service{health_router(std::move(payload))};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
auto payload = healthy_payload();
payload.as_object().erase(field);
auto service = http_service{health_router(std::move(payload))};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
for (auto payload : {boost::json::value{}, boost::json::value{true}, boost::json::value{boost::json::array{}}}) {
auto service = http_service{health_router(std::move(payload))};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
}
BOOST_AUTO_TEST_CASE(health_rejects_malformed_or_failed_envelopes_and_missing_routes) {
for (std::char_t const *body : {"not JSON", "{}", "[]", "{\"ok\":true}", "{\"ok\":false}",
"{\"device\":\"zero\",\"product\":\"product-vamp\",\"status\":\"ready\"}"}) {
auto router = ligo::service_router{};
router.add_response_route("GET", "/api/health", [body](ligo::request_context, boost::json::value) -> boost::asio::awaitable<std::expected<ligo::service_response, modulus::error>> {
co_return ligo::text_response("application/json", body);
});
auto service = http_service{std::move(router)};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
auto service = http_service{ligo::service_router{}};
BOOST_TEST(!vamp_zero::check_health(service.config()));
}
BOOST_AUTO_TEST_CASE(health_fails_when_stopped_and_obeys_timeout) {
auto config = vamp_zero::config{};
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
config.control.endpoint.timeout = std::chrono::milliseconds{50};
BOOST_TEST(!vamp_zero::check_health(config));
auto io = boost::asio::io_context{};
auto stalled = boost::asio::ip::tcp::acceptor{io, {boost::asio::ip::make_address("127.0.0.1"), 0}};
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(stalled.local_endpoint().port());
auto started = std::chrono::steady_clock::now();
BOOST_TEST(!vamp_zero::check_health(config));
auto elapsed = std::chrono::steady_clock::now() - started;
BOOST_TEST((elapsed < std::chrono::seconds{2}));
}
BOOST_AUTO_TEST_CASE(health_command_never_mutates_database_assets_or_runtime) {
auto root = temporary_directory{};
auto service = http_service{health_router(healthy_payload())};
auto config = fixture_config(root.path);
config.control = service.config().control;
config.runtime.exit_after_startup = true;
auto log_level = modulus::default_logger().level();
auto error_config = modulus::default_error_config();
config.modulus.logging.level = log_level == modulus::log_level::DEBUG ? modulus::log_level::ERROR : modulus::log_level::DEBUG;
config.modulus.errors.include_location = !error_config.include_location;
auto command = vamp_zero::command_line{.config_path = config_file(root.path, config), .health_check = true};
BOOST_TEST(vamp_zero::run(command).has_value());
BOOST_TEST((modulus::default_logger().level() == log_level));
BOOST_TEST(modulus::default_error_config().include_location == error_config.include_location);
BOOST_TEST(!std::filesystem::exists(root.path / "db"));
BOOST_TEST(!std::filesystem::exists(root.path / "assets"));
BOOST_TEST(!std::filesystem::exists(root.path / "run"));
write_file(config.database.path, "not a database");
write_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "invalid.json", "not JSON");
write_file(config.runtime.heartbeat_path, "stale heartbeat");
auto database_time = std::filesystem::last_write_time(config.database.path);
auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path);
BOOST_TEST(vamp_zero::run(command).has_value());
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
command.config_path = config_file(root.path, config);
BOOST_TEST(!vamp_zero::run(command));
BOOST_TEST((modulus::default_logger().level() == log_level));
BOOST_TEST(modulus::default_error_config().include_location == error_config.include_location);
BOOST_TEST(read_file(config.database.path) == "not a database");
BOOST_TEST(read_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "invalid.json") == "not JSON");
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "stale heartbeat");
BOOST_TEST((std::filesystem::last_write_time(config.database.path) == database_time));
BOOST_TEST((std::filesystem::last_write_time(config.runtime.heartbeat_path) == heartbeat_time));
BOOST_TEST(!std::filesystem::exists(config.database.path + "-wal"));
require_no_probe(config, true);
}
BOOST_AUTO_TEST_CASE(portal_health_route_reports_readiness_without_changing_control_state) {
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
auto service = http_service{vamp_zero::make_router(state)};
BOOST_TEST(vamp_zero::check_health(service.config()).has_value());
auto snapshot = state.snapshot();
BOOST_TEST(snapshot.heartbeat_count == 0U);
BOOST_TEST(!snapshot.pico_connected);
BOOST_TEST(snapshot.last_command == "startup");
}
BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_background_polls) {
for (auto const *route : {vamp_protocol::remote_next_path, vamp_protocol::remote_previous_path, vamp_protocol::remote_select_path}) {
auto state = vamp_zero::control_state{vamp_zero::config{}, vamp_zero::app_state{}};
auto catalog = state.snapshot().catalog;
auto router = vamp_zero::make_router(state);
auto io = boost::asio::io_context{};
auto response = boost::asio::co_spawn(io, router.call_response({.method = "POST", .route = route}, {}), boost::asio::use_future);
io.run();
BOOST_REQUIRE(response.get());
auto snapshot = state.snapshot();
BOOST_TEST(snapshot.pico_connected);
BOOST_TEST(snapshot.heartbeat_count == 1U);
BOOST_TEST(snapshot.catalog.get() == catalog.get());
}
}
BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_heartbeat) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto database_time = std::filesystem::last_write_time(config.database.path);
for (auto const &entry : std::filesystem::recursive_directory_iterator{config.runtime.assets_path}) {
if (entry.path().extension() == ".json") {
std::filesystem::remove(entry.path());
}
}
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
BOOST_TEST(std::filesystem::exists(config.database.path));
BOOST_TEST((std::filesystem::last_write_time(config.database.path) == database_time));
BOOST_TEST(!std::filesystem::exists(config.database.path + "-wal"));
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
require_no_probe(config);
auto io = boost::asio::io_context{};
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
io.run();
auto songs = performance.get();
auto exercises = practice.get();
BOOST_REQUIRE(songs);
BOOST_REQUIRE(exercises);
BOOST_TEST(songs->parts.size() == 2U);
BOOST_REQUIRE_EQUAL(exercises->exercises.size(), 1U);
BOOST_TEST(exercises->exercises.front().parts.size() == 3U);
write_file(config.runtime.heartbeat_path, "existing heartbeat");
auto heartbeat_time = std::filesystem::last_write_time(config.runtime.heartbeat_path);
config.runtime.exit_after_startup = true;
ready = vamp_zero::run({.config_path = config_file(root.path, config)});
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "existing heartbeat");
BOOST_TEST((std::filesystem::last_write_time(config.runtime.heartbeat_path) == heartbeat_time));
require_no_probe(config, true);
}
BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_and_parts) {
for (std::char_t const *relative : {"songs/first.pages/2.png", "songs/second.pages/2.png", "practice/001/electric.pages/2.png"}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
std::filesystem::remove(std::filesystem::path{config.runtime.assets_path} / relative);
auto ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(std::string{ready.error().message()}.find("page is missing") != std::string::npos);
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
}
}
BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_root) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto assets = std::filesystem::path{config.runtime.assets_path};
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR};
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive);
auto path = assets / "practice/001/acoustic.json";
write_score(path, "Acoustic");
auto score = boost::json::parse(read_file(path));
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path));
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
auto &sample = score.at("playback").at("samples").as_array().front().as_object();
auto audio = assets / std::string{sample.at("path").as_string()};
auto corrupt = read_file(audio);
corrupt[0] = 'X';
sample["sha256"] = checksum(corrupt);
write_file(audio, corrupt);
write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path));
ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
}
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
for (std::bool_t escape : {false, true}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto page = std::filesystem::path{config.runtime.assets_path} / "songs" / "second.pages" / "2.png";
if (escape) {
write_file(root.path / "outside.png", fixture_page());
std::filesystem::remove(page);
std::filesystem::create_symlink(root.path / "outside.png", page);
} else {
auto bytes = fixture_page();
bytes.back() ^= 1;
write_file(page, bytes);
}
auto ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
auto expected = std::string_view{escape ? "escapes its asset directory" : "checksum mismatch"};
BOOST_TEST(std::string{ready.error().message()}.find(expected) != std::string::npos);
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_malformed_catalogues) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
BOOST_TEST(!run_once(root.path, config));
make_assets(config);
write_file(config.database.path, "not a database");
BOOST_TEST(!run_once(root.path, config));
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
}
BOOST_AUTO_TEST_CASE(readiness_rejects_unusable_database_and_runtime_paths) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
write_file(root.path / "blocked", "regular file");
config.database.path = (root.path / "blocked" / "vamp.db").string();
BOOST_TEST(!run_once(root.path, config));
config.database.path = (root.path / "db" / "vamp.db").string();
config.runtime.heartbeat_path = (root.path / "blocked" / "heartbeat.json").string();
BOOST_TEST(!run_once(root.path, config));
BOOST_TEST(read_file(root.path / "blocked") == "regular file");
}
BOOST_AUTO_TEST_CASE(readiness_decodes_pages_even_when_checksums_match) {
for (auto kind : {"truncated", "missing pixels", "wrong dimensions", "oversized"}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto bytes = fixture_page(std::string_view{kind} == "wrong dimensions" ? 10 : 976);
if (std::string_view{kind} == "truncated") {
bytes.resize(24U);
} else if (std::string_view{kind} == "missing pixels") {
bytes.resize(33U);
} else if (std::string_view{kind} == "oversized") {
bytes[16] = 127;
}
auto score_path = std::filesystem::path{config.runtime.assets_path} / "songs" / "first.json";
auto score = boost::json::parse(read_file(score_path));
auto &page = score.at("presentation").at("pages").as_array()[0].as_object();
write_file(score_path.parent_path() / std::string{page.at("path").as_string()}, bytes);
page["sha256"] = checksum(bytes);
write_file(score_path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(config.runtime.assets_path, config.database.path));
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(!ready, kind);
BOOST_TEST(ready.error().describe().find("first.pages/1.png") != std::string::npos);
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_actual_heartbeat_directories_and_symlinks) {
for (auto kind : {"directory", "symlink", "dangling symlink"}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
std::filesystem::create_directories(std::filesystem::path{config.runtime.heartbeat_path}.parent_path());
if (std::string_view{kind} == "directory") {
std::filesystem::create_directory(config.runtime.heartbeat_path);
} else {
if (std::string_view{kind} == "symlink") {
write_file(root.path / "target", "untouched");
}
std::filesystem::create_symlink(root.path / "target", config.runtime.heartbeat_path);
}
auto ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(ready.error().describe().find(config.runtime.heartbeat_path) != std::string::npos);
require_no_probe(config, true);
if (std::string_view{kind} == "symlink") {
BOOST_TEST(read_file(root.path / "target") == "untouched");
}
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_unwritable_parent_without_leaving_probes) {
if (::geteuid() == 0) {
BOOST_TEST_MESSAGE("Permission check requires an unprivileged test process");
return;
}
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto parent = std::filesystem::path{config.runtime.heartbeat_path}.parent_path();
std::filesystem::create_directory(parent);
std::filesystem::permissions(parent, std::filesystem::perms::owner_read | std::filesystem::perms::owner_exec);
auto ready = run_once(root.path, config);
std::filesystem::permissions(parent, std::filesystem::perms::owner_all);
BOOST_TEST(!ready);
require_no_probe(config);
}
BOOST_AUTO_TEST_CASE(readiness_rejects_occupied_bind_and_releases_successful_probe) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto service = http_service{health_router(healthy_payload())};
config.control = service.config().control;
write_file(config.runtime.heartbeat_path, "existing heartbeat");
BOOST_TEST(!run_once(root.path, config));
BOOST_TEST(read_file(config.runtime.heartbeat_path) == "existing heartbeat");
require_no_probe(config, true);
config.control.endpoint.bind = "127.0.0.1:" + std::to_string(unused_port());
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
auto io = boost::asio::io_context{};
auto endpoint = ligo::http::server_endpoint_from_config(config.control.endpoint);
BOOST_REQUIRE(endpoint);
auto server = ligo::http::server{io, *endpoint, {}};
BOOST_TEST(server.start().has_value());
server.stop();
io.run();
require_no_probe(config, true);
}