571 lines
26 KiB
C++
571 lines
26 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/control.hpp>
|
|
|
|
#include "catalog_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::filesystem::path const &assets, std::string part = "Lead") -> void {
|
|
auto score = boost::json::parse(R"({"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 title = std::string{"VPTI\x28\x01\x40\x00", 8U};
|
|
title.resize(vamp_protocol::title_image_size, std::char_t{0x11});
|
|
auto title_image = checksum(title);
|
|
write_file(assets / "pico" / "titles" / (title_image + ".mask"), title);
|
|
score.at("presentation").as_object()["title_image"] = title_image;
|
|
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", root);
|
|
write_score(root / "songs" / "second.json", root);
|
|
BOOST_REQUIRE(vamp_test::build_catalog(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(control_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.input_revision == 0U);
|
|
BOOST_TEST(!snapshot.pico_connected);
|
|
BOOST_CHECK(snapshot.mode == vamp_zero::app_mode::MAIN_MENU);
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(control_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.input_revision == 1U);
|
|
BOOST_TEST(snapshot.catalog.get() == catalog.get());
|
|
}
|
|
}
|
|
|
|
BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalog_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_catalog(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
|
|
io.run();
|
|
auto songs = performance.get();
|
|
BOOST_REQUIRE(songs);
|
|
BOOST_TEST(songs->parts.size() == 2U);
|
|
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"}) {
|
|
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_rejects_missing_or_corrupted_pico_titles) {
|
|
for (auto corrupt : {false, true}) {
|
|
auto root = temporary_directory{};
|
|
auto config = fixture_config(root.path);
|
|
make_assets(config);
|
|
auto titles = std::filesystem::path{config.runtime.assets_path} / "pico" / "titles";
|
|
auto path = std::filesystem::directory_iterator {
|
|
titles
|
|
} -> path();
|
|
if (corrupt) {
|
|
auto bytes = read_file(path);
|
|
bytes.back() ^= 1;
|
|
write_file(path, bytes);
|
|
} else {
|
|
std::filesystem::remove(path);
|
|
}
|
|
auto ready = run_once(root.path, config);
|
|
BOOST_REQUIRE(!ready);
|
|
BOOST_TEST(std::string{ready.error().message()}.find("Pico title") != std::string::npos);
|
|
require_no_probe(config);
|
|
}
|
|
}
|
|
|
|
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 / "songs/acoustic.json";
|
|
write_score(path, assets, "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_catalog(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_catalog(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_catalogs) {
|
|
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_catalog(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);
|
|
}
|