2026-03-24 12:10:47 -04:00
|
|
|
#include <gtest/gtest.h>
|
2026-03-27 15:26:44 -04:00
|
|
|
#include <chrono>
|
2026-03-24 12:10:47 -04:00
|
|
|
#include <cstdlib>
|
|
|
|
|
#include <filesystem>
|
2026-03-27 15:26:44 -04:00
|
|
|
#include <fstream>
|
2026-03-24 12:10:47 -04:00
|
|
|
#include <string>
|
2026-03-31 16:28:52 +02:00
|
|
|
#include <sys/wait.h>
|
2026-03-24 12:10:47 -04:00
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
#ifdef __APPLE__
|
|
|
|
|
#include <mach-o/dyld.h>
|
|
|
|
|
#elif defined(__linux__)
|
|
|
|
|
#include <unistd.h>
|
|
|
|
|
#include <climits>
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
|
|
|
|
|
|
// Helper function to get the directory of the current executable
|
|
|
|
|
static fs::path getExecutableDir() {
|
|
|
|
|
#ifdef __APPLE__
|
|
|
|
|
char path[PATH_MAX];
|
|
|
|
|
uint32_t size = sizeof(path);
|
|
|
|
|
if (_NSGetExecutablePath(path, &size) == 0) {
|
|
|
|
|
return fs::path(path).parent_path();
|
|
|
|
|
}
|
|
|
|
|
#elif defined(__linux__)
|
|
|
|
|
char path[PATH_MAX];
|
|
|
|
|
ssize_t len = readlink("/proc/self/exe", path, sizeof(path) - 1);
|
|
|
|
|
if (len != -1) {
|
|
|
|
|
path[len] = '\0';
|
|
|
|
|
return fs::path(path).parent_path();
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
return fs::path();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class CLITest : public ::testing::Test {
|
|
|
|
|
protected:
|
2026-08-03 21:49:15 -03:00
|
|
|
fs::path logosctlBinary;
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
void SetUp() override {
|
2026-08-03 21:49:15 -03:00
|
|
|
// Check for LOGOSCTL_BINARY environment variable first
|
|
|
|
|
const char* envBinary = std::getenv("LOGOSCTL_BINARY");
|
2026-03-24 12:10:47 -04:00
|
|
|
if (envBinary && fs::exists(envBinary)) {
|
2026-08-03 21:49:15 -03:00
|
|
|
logosctlBinary = envBinary;
|
2026-03-24 12:10:47 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
// Get the directory where the test executable is located
|
|
|
|
|
fs::path execDir = getExecutableDir();
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-08-03 21:49:15 -03:00
|
|
|
// Find the logosctl binary - try multiple locations
|
2026-03-24 12:10:47 -04:00
|
|
|
std::vector<fs::path> searchPaths;
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
// First, check in the same directory as the test executable (Nix builds)
|
|
|
|
|
if (!execDir.empty()) {
|
2026-08-03 21:49:15 -03:00
|
|
|
searchPaths.push_back(execDir / "logosctl");
|
2026-03-24 12:10:47 -04:00
|
|
|
}
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
// Then try paths relative to current working directory
|
2026-08-03 21:49:15 -03:00
|
|
|
searchPaths.push_back(fs::current_path() / ".." / "bin" / "logosctl");
|
|
|
|
|
searchPaths.push_back(fs::current_path() / "bin" / "logosctl");
|
|
|
|
|
searchPaths.push_back(fs::current_path() / ".." / ".." / "bin" / "logosctl");
|
|
|
|
|
searchPaths.push_back(fs::current_path().parent_path() / "logosctl");
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
for (const auto& path : searchPaths) {
|
|
|
|
|
if (fs::exists(path)) {
|
2026-08-03 21:49:15 -03:00
|
|
|
logosctlBinary = fs::canonical(path);
|
2026-03-24 12:10:47 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
// Binary not found, skip tests
|
|
|
|
|
std::string triedPaths;
|
|
|
|
|
for (size_t i = 0; i < searchPaths.size(); ++i) {
|
|
|
|
|
if (i > 0) triedPaths += ", ";
|
|
|
|
|
triedPaths += "\"" + searchPaths[i].string() + "\"";
|
|
|
|
|
}
|
2026-08-03 21:49:15 -03:00
|
|
|
GTEST_SKIP() << "logosctl binary not found. Set LOGOSCTL_BINARY env var or build the binary first. Tried: "
|
2026-03-24 12:10:47 -04:00
|
|
|
<< triedPaths;
|
|
|
|
|
}
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-08-03 21:49:15 -03:00
|
|
|
// Helper to run logosctl command
|
|
|
|
|
int runLogosctl(const std::string& args, std::string* output = nullptr) {
|
|
|
|
|
std::string cmd = logosctlBinary.string() + " " + args;
|
2026-03-24 12:10:47 -04:00
|
|
|
if (output) {
|
|
|
|
|
cmd += " 2>&1";
|
|
|
|
|
FILE* pipe = popen(cmd.c_str(), "r");
|
|
|
|
|
if (!pipe) return -1;
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
char buffer[128];
|
|
|
|
|
while (fgets(buffer, sizeof(buffer), pipe)) {
|
|
|
|
|
*output += buffer;
|
|
|
|
|
}
|
|
|
|
|
int status = pclose(pipe);
|
|
|
|
|
return WEXITSTATUS(status);
|
|
|
|
|
} else {
|
|
|
|
|
int status = system(cmd.c_str());
|
|
|
|
|
return WEXITSTATUS(status);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-08-03 21:49:15 -03:00
|
|
|
// Helper to run logosctl with timeout (for commands that run event loop)
|
|
|
|
|
int runLogosctlWithTimeout(const std::string& args, std::string* output, int timeoutSecs = 2) {
|
|
|
|
|
std::string cmd = "timeout " + std::to_string(timeoutSecs) + " " + logosctlBinary.string() + " " + args + " 2>&1";
|
2026-03-24 12:10:47 -04:00
|
|
|
FILE* pipe = popen(cmd.c_str(), "r");
|
|
|
|
|
if (!pipe) return -1;
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
char buffer[128];
|
|
|
|
|
while (fgets(buffer, sizeof(buffer), pipe)) {
|
|
|
|
|
*output += buffer;
|
|
|
|
|
}
|
|
|
|
|
int status = pclose(pipe);
|
|
|
|
|
return WEXITSTATUS(status);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-24 17:28:59 -04:00
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
// Help and version tests
|
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
TEST_F(CLITest, HelpCommand) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("--help", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
EXPECT_EQ(exitCode, 0);
|
2026-03-24 17:28:59 -04:00
|
|
|
// New help text includes subcommands
|
2026-08-03 21:49:15 -03:00
|
|
|
EXPECT_NE(output.find("logosctl"), std::string::npos) << "Help should contain app name";
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_NE(output.find("status"), std::string::npos) << "Help should list status command";
|
2026-08-03 21:49:15 -03:00
|
|
|
// Help lists the groups, not the internal hyphenated dispatch tokens.
|
|
|
|
|
EXPECT_NE(output.find("module"), std::string::npos) << "Help should list the module group";
|
|
|
|
|
EXPECT_NE(output.find("package"), std::string::npos) << "Help should list the package group";
|
|
|
|
|
EXPECT_NE(output.find("catalog"), std::string::npos) << "Help should list the catalog group";
|
|
|
|
|
EXPECT_EQ(output.find("load-module"), std::string::npos)
|
|
|
|
|
<< "load-module is an internal dispatch token and must stay out of help";
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_NE(output.find("call"), std::string::npos) << "Help should list call command";
|
|
|
|
|
EXPECT_NE(output.find("watch"), std::string::npos) << "Help should list watch command";
|
|
|
|
|
EXPECT_NE(output.find("--json"), std::string::npos) << "Help should document --json flag";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, HelpShortFlag) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("-h", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_EQ(exitCode, 0);
|
2026-08-03 21:49:15 -03:00
|
|
|
EXPECT_NE(output.find("logosctl"), std::string::npos);
|
2026-03-24 12:10:47 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, VersionCommand) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("--version", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
|
2026-03-24 12:10:47 -04:00
|
|
|
EXPECT_EQ(exitCode, 0);
|
2026-06-26 16:08:25 -03:00
|
|
|
// The version string is build-derived (release version / pre-release sha /
|
|
|
|
|
// "dev"), so assert on the stable tool-name prefix rather than a literal
|
|
|
|
|
// version number.
|
2026-08-03 21:49:15 -03:00
|
|
|
EXPECT_NE(output.find("logosctl version"), std::string::npos) << "Version output should identify logosctl";
|
2026-03-24 12:10:47 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 17:28:59 -04:00
|
|
|
TEST_F(CLITest, NoArgs_ShowsHelp) {
|
2026-03-24 12:10:47 -04:00
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_EQ(exitCode, 0);
|
2026-08-03 21:49:15 -03:00
|
|
|
EXPECT_NE(output.find("logosctl"), std::string::npos) << "No args should show help";
|
2026-03-24 17:28:59 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
// Client commands without daemon (should fail gracefully)
|
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, Status_NoDaemon) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("status --json", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
// Should report not_running (exit 1) or connection error (exit 2)
|
|
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, ListModules_NoDaemon) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("list-modules --json", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, LoadModule_NoDaemon) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("load-module waku --json", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, ModuleInfo_NoDaemon) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("module-info chat --json", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, Stats_NoDaemon) {
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("stats --json", &output);
|
2026-03-24 17:28:59 -04:00
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 15:26:44 -04:00
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
// Timing tests — client commands must return quickly (catches RPC hangs)
|
|
|
|
|
// If the RPC layer has a misconfigured token key or missing timeout,
|
|
|
|
|
// commands hang for 20+ seconds waiting for capability_module negotiation.
|
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, Status_NoDaemon_ReturnsFast) {
|
|
|
|
|
auto start = std::chrono::steady_clock::now();
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("status --json", &output);
|
2026-03-27 15:26:44 -04:00
|
|
|
auto elapsed = std::chrono::steady_clock::now() - start;
|
|
|
|
|
|
|
|
|
|
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
|
|
|
|
|
EXPECT_LE(secs, 5) << "status should return within 5 seconds (took " << secs << "s). "
|
|
|
|
|
<< "Likely an RPC timeout or token key misconfiguration.";
|
|
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, LoadModule_NoDaemon_ReturnsFast) {
|
|
|
|
|
auto start = std::chrono::steady_clock::now();
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("load-module test --json", &output);
|
2026-03-27 15:26:44 -04:00
|
|
|
auto elapsed = std::chrono::steady_clock::now() - start;
|
|
|
|
|
|
|
|
|
|
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
|
|
|
|
|
EXPECT_LE(secs, 5) << "load-module should return within 5 seconds (took " << secs << "s).";
|
|
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, Stop_NoDaemon_ReturnsFast) {
|
|
|
|
|
auto start = std::chrono::steady_clock::now();
|
|
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("stop --json", &output);
|
2026-03-27 15:26:44 -04:00
|
|
|
auto elapsed = std::chrono::steady_clock::now() - start;
|
|
|
|
|
|
|
|
|
|
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
|
|
|
|
|
EXPECT_LE(secs, 5) << "stop should return within 5 seconds (took " << secs << "s).";
|
|
|
|
|
EXPECT_NE(exitCode, 0);
|
|
|
|
|
}
|
2026-03-27 16:51:13 -04:00
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
2026-08-03 21:49:15 -03:00
|
|
|
// Configuration moved from flags to the session's YAML documents.
|
2026-03-27 16:51:13 -04:00
|
|
|
//
|
2026-08-03 21:49:15 -03:00
|
|
|
// The old per-flag surface (-m/--modules-dir, --persistence-path,
|
|
|
|
|
// --module-transport, --insecure-tcp, --access-policy, --access-group,
|
|
|
|
|
// --persist-config, and the seven --client-* dial flags) is gone. What those
|
|
|
|
|
// flags used to validate is now validated when the document is installed, so
|
|
|
|
|
// these tests moved with it: a bad value is rejected by `daemon config set` /
|
|
|
|
|
// `client config set` rather than at parse time.
|
2026-03-27 16:51:13 -04:00
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
2026-08-03 21:49:15 -03:00
|
|
|
TEST_F(CLITest, Help_ShowsOnlyTheSurvivingGlobalFlags) {
|
2026-03-27 16:51:13 -04:00
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctl("--help", &output);
|
2026-04-09 17:07:42 -03:00
|
|
|
EXPECT_EQ(exitCode, 0);
|
|
|
|
|
|
2026-08-03 21:49:15 -03:00
|
|
|
// --config-dir survives because it selects *which* session to act on, so
|
|
|
|
|
// it cannot itself live inside one.
|
|
|
|
|
EXPECT_NE(output.find("--config-dir"), std::string::npos)
|
2026-06-15 10:52:45 -04:00
|
|
|
<< "Output:\n" << output;
|
|
|
|
|
|
2026-08-03 21:49:15 -03:00
|
|
|
for (const char* gone : {"--modules-dir", "--persistence-path",
|
|
|
|
|
"--module-transport", "--insecure-tcp",
|
|
|
|
|
"--access-policy", "--access-group",
|
|
|
|
|
"--persist-config", "--client-transport",
|
|
|
|
|
"--client-codec", "--token-file", "--ssl-ca"}) {
|
|
|
|
|
EXPECT_EQ(output.find(gone), std::string::npos)
|
|
|
|
|
<< gone << " should no longer exist as a flag. Output:\n" << output;
|
2026-06-15 10:52:45 -04:00
|
|
|
}
|
2026-08-03 21:49:15 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, RemovedFlags_AreRejectedNotIgnored) {
|
|
|
|
|
// Silently accepting a flag that no longer does anything would leave the
|
|
|
|
|
// operator's intent unapplied with nothing to explain it.
|
2026-06-15 10:52:45 -04:00
|
|
|
std::string output;
|
2026-08-03 21:49:15 -03:00
|
|
|
int exitCode = runLogosctlWithTimeout("-D --modules-dir /tmp/x", &output, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 109)
|
|
|
|
|
<< "A removed flag must be a parse error, not ignored (and must not "
|
|
|
|
|
"start a daemon -- 124 would mean it did). Output:\n" << output;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_RejectsMalformedYaml) {
|
|
|
|
|
const fs::path cfgDir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_badyaml_" + std::to_string(::getpid()));
|
|
|
|
|
fs::create_directories(cfgDir);
|
|
|
|
|
const fs::path doc = cfgDir / "bad.yaml";
|
|
|
|
|
{ std::ofstream ofs(doc, std::ios::trunc); ofs << "modules:\n - [unclosed\n"; }
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
|
|
|
|
|
// The existing config must survive a rejected document.
|
|
|
|
|
EXPECT_FALSE(fs::exists(cfgDir / "daemon" / "config.yaml"))
|
|
|
|
|
<< "A malformed document must not be written.";
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_RejectsUnknownKeys) {
|
|
|
|
|
// `insecureTcp` is a near-miss for `insecure_tcp`. The loader ignores
|
|
|
|
|
// unrecognised keys, so without this check the daemon would boot with the
|
|
|
|
|
// operator's intent silently dropped.
|
|
|
|
|
const fs::path cfgDir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_badkey_" + std::to_string(::getpid()));
|
|
|
|
|
fs::create_directories(cfgDir);
|
|
|
|
|
const fs::path doc = cfgDir / "typo.yaml";
|
|
|
|
|
{ std::ofstream ofs(doc, std::ios::trunc); ofs << "insecureTcp: true\n"; }
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
|
|
|
|
|
EXPECT_NE(output.find("insecure_tcp"), std::string::npos)
|
|
|
|
|
<< "The error should name the correct spelling. Output:\n" << output;
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_RoundTripsThroughShow) {
|
|
|
|
|
const fs::path cfgDir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_rt_" + std::to_string(::getpid()));
|
|
|
|
|
fs::create_directories(cfgDir);
|
|
|
|
|
const fs::path doc = cfgDir / "node.yaml";
|
|
|
|
|
{
|
|
|
|
|
std::ofstream ofs(doc, std::ios::trunc);
|
|
|
|
|
ofs << "insecure_tcp: true\n"
|
|
|
|
|
"modules:\n"
|
|
|
|
|
" core_service:\n"
|
|
|
|
|
" - protocol: tcp\n"
|
|
|
|
|
" host: 127.0.0.1\n"
|
|
|
|
|
" port: 8645\n";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
ASSERT_EQ(exitCode, 0) << "Output:\n" << output;
|
|
|
|
|
|
|
|
|
|
std::string shown;
|
|
|
|
|
exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config show --human", &shown, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 0) << "Output:\n" << shown;
|
|
|
|
|
EXPECT_NE(shown.find("8645"), std::string::npos) << "Output:\n" << shown;
|
|
|
|
|
EXPECT_NE(shown.find("insecure_tcp"), std::string::npos) << "Output:\n" << shown;
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_AcceptsEverySignaturePolicyValue) {
|
|
|
|
|
for (const char* policy : {"none", "warn", "require"}) {
|
|
|
|
|
const fs::path cfgDir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_sigok_" + std::string(policy) + "_" +
|
|
|
|
|
std::to_string(::getpid()));
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
fs::create_directories(cfgDir);
|
|
|
|
|
const fs::path doc = cfgDir / "node.yaml";
|
|
|
|
|
{ std::ofstream ofs(doc, std::ios::trunc);
|
|
|
|
|
ofs << "signature_policy: " << policy << "\n"; }
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 0) << policy << " Output:\n" << output;
|
|
|
|
|
|
|
|
|
|
std::string shown;
|
|
|
|
|
exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config show --human",
|
|
|
|
|
&shown, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 0) << "Output:\n" << shown;
|
|
|
|
|
EXPECT_NE(shown.find(policy), std::string::npos) << "Output:\n" << shown;
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_RejectsUnknownSignaturePolicy) {
|
|
|
|
|
// package_manager ignores a policy it does not recognise, so `required`
|
|
|
|
|
// (the key takes `require`) would leave it on the default `warn` while
|
|
|
|
|
// `daemon config show` kept reporting the operator's stricter intent.
|
|
|
|
|
const fs::path cfgDir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_sigbad_" + std::to_string(::getpid()));
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
fs::create_directories(cfgDir);
|
|
|
|
|
const fs::path doc = cfgDir / "node.yaml";
|
|
|
|
|
{ std::ofstream ofs(doc, std::ios::trunc); ofs << "signature_policy: required\n"; }
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
|
|
|
|
|
EXPECT_NE(output.find("require"), std::string::npos)
|
|
|
|
|
<< "The error should name the accepted values. Output:\n" << output;
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonStart_RefusesTlsListenerWithNoCertificate) {
|
|
|
|
|
// A tcp_ssl listener with no material binds fine and then fails every
|
|
|
|
|
// handshake with "no shared cipher", which reads like a client fault.
|
|
|
|
|
const fs::path cfgDir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_nocert_" + std::to_string(::getpid()));
|
2026-06-15 10:52:45 -04:00
|
|
|
fs::remove_all(cfgDir);
|
2026-08-03 21:49:15 -03:00
|
|
|
fs::create_directories(cfgDir);
|
|
|
|
|
const fs::path doc = cfgDir / "node.yaml";
|
|
|
|
|
{
|
|
|
|
|
std::ofstream ofs(doc, std::ios::trunc);
|
|
|
|
|
ofs << "modules:\n"
|
|
|
|
|
" core_service:\n"
|
|
|
|
|
" - protocol: tcp_ssl\n"
|
|
|
|
|
" host: 127.0.0.1\n"
|
|
|
|
|
" port: 8645\n";
|
|
|
|
|
}
|
|
|
|
|
std::string output;
|
|
|
|
|
ASSERT_EQ(runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
|
|
|
|
|
&output, 5), 0) << "Output:\n" << output;
|
|
|
|
|
|
|
|
|
|
output.clear();
|
|
|
|
|
// 124 would mean a daemon actually started on a certificate-less listener.
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " -D", &output, 15);
|
|
|
|
|
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
|
|
|
|
|
EXPECT_NE(output.find("core_service"), std::string::npos)
|
|
|
|
|
<< "The error should name the listener. Output:\n" << output;
|
|
|
|
|
EXPECT_NE(output.find("ssl:"), std::string::npos)
|
|
|
|
|
<< "The error should name the top-level block as one of the two places "
|
|
|
|
|
"the material can come from. Output:\n" << output;
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigShow_AbsentIsNotAnError) {
|
|
|
|
|
// A session with no config runs on defaults; that is a normal state.
|
|
|
|
|
const fs::path cfgDir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_absent_" + std::to_string(::getpid()));
|
|
|
|
|
fs::create_directories(cfgDir);
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + cfgDir.string() + " daemon config show --human", &output, 5);
|
|
|
|
|
EXPECT_EQ(exitCode, 0) << "Output:\n" << output;
|
|
|
|
|
fs::remove_all(cfgDir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
// A malformed document must be an error, never a crash, and never a write.
|
|
|
|
|
//
|
|
|
|
|
// The reader used nlohmann's `json::value(key, default)`, which throws when the
|
|
|
|
|
// key is present with a different type than the default. Nothing caught it, so
|
|
|
|
|
// `modules_dirs: /single/path` — a scalar where a list belongs — terminated the
|
|
|
|
|
// binary:
|
|
|
|
|
//
|
|
|
|
|
// libc++abi: terminating due to uncaught exception of type
|
|
|
|
|
// nlohmann::detail::type_error: [json.exception.type_error.302]
|
|
|
|
|
// type must be array, but is string
|
|
|
|
|
//
|
|
|
|
|
// A process killed by SIGABRT surfaces here as exit 134, so asserting exit 1
|
|
|
|
|
// is what distinguishes "reported it" from "died on it".
|
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
// Per-test config dir plus a document to feed `config set`. Named after the
|
|
|
|
|
// test so parallel cases never share one.
|
|
|
|
|
struct ConfigFixture {
|
|
|
|
|
fs::path dir;
|
|
|
|
|
fs::path doc;
|
|
|
|
|
|
|
|
|
|
ConfigFixture(const std::string& name, const std::string& body)
|
|
|
|
|
{
|
|
|
|
|
dir = fs::temp_directory_path() /
|
|
|
|
|
("logosctl_cli_" + name + "_" + std::to_string(::getpid()));
|
|
|
|
|
fs::remove_all(dir);
|
|
|
|
|
fs::create_directories(dir);
|
|
|
|
|
doc = dir / "doc.yaml";
|
|
|
|
|
std::ofstream ofs(doc, std::ios::trunc);
|
|
|
|
|
ofs << body;
|
|
|
|
|
}
|
|
|
|
|
~ConfigFixture() { fs::remove_all(dir); }
|
|
|
|
|
|
|
|
|
|
fs::path daemonConfig() const { return dir / "daemon" / "config.yaml"; }
|
|
|
|
|
fs::path clientConfig() const { return dir / "client" / "config.yaml"; }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_TypeMismatchIsReportedNotFatal) {
|
|
|
|
|
ConfigFixture fx("typemismatch", "modules_dirs: /single/path\n");
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + fx.dir.string() + " daemon config set " + fx.doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
|
2026-06-15 10:52:45 -04:00
|
|
|
EXPECT_EQ(exitCode, 1)
|
2026-08-03 21:49:15 -03:00
|
|
|
<< "A mistyped value must be reported (exit 1), not abort the process "
|
|
|
|
|
"(exit 134). Output:\n" << output;
|
|
|
|
|
EXPECT_NE(output.find("modules_dirs"), std::string::npos)
|
|
|
|
|
<< "The error must name the offending key. Output:\n" << output;
|
|
|
|
|
EXPECT_EQ(output.find("terminating due to uncaught exception"), std::string::npos)
|
2026-06-15 10:52:45 -04:00
|
|
|
<< "Output:\n" << output;
|
2026-08-03 21:49:15 -03:00
|
|
|
EXPECT_FALSE(fs::exists(fx.daemonConfig()))
|
|
|
|
|
<< "A document that cannot be understood must not be written.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_SchemaInvalidDocumentIsNotWritten) {
|
|
|
|
|
// This document passes the YAML parse and the key allowlist, and fails
|
|
|
|
|
// only the schema. Validation used to run AFTER the write, so the command
|
|
|
|
|
// exited 1 having already installed a config the daemon would refuse to
|
|
|
|
|
// boot from.
|
|
|
|
|
ConfigFixture fx("schemainvalid",
|
|
|
|
|
"modules:\n"
|
|
|
|
|
" core_service:\n"
|
|
|
|
|
" - protocol: tcpp\n"
|
|
|
|
|
" host: 127.0.0.1\n"
|
|
|
|
|
" port: 8645\n");
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + fx.dir.string() + " daemon config set " + fx.doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
|
|
|
|
|
EXPECT_FALSE(fs::exists(fx.daemonConfig()))
|
|
|
|
|
<< "A rejected document must not be left on disk — the session would "
|
|
|
|
|
"be holding a config the daemon cannot boot from.";
|
|
|
|
|
EXPECT_NE(output.find("protocol"), std::string::npos)
|
|
|
|
|
<< "The error must name the offending key. Output:\n" << output;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, DaemonConfigSet_RejectionLeavesThePreviousConfigIntact) {
|
|
|
|
|
ConfigFixture fx("preserve", "insecure_tcp: true\n");
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
ASSERT_EQ(runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + fx.dir.string() + " daemon config set " + fx.doc.string(),
|
|
|
|
|
&output, 5), 0) << "Output:\n" << output;
|
|
|
|
|
ASSERT_TRUE(fs::exists(fx.daemonConfig()));
|
|
|
|
|
|
|
|
|
|
const fs::path bad = fx.dir / "bad.yaml";
|
|
|
|
|
{ std::ofstream ofs(bad, std::ios::trunc); ofs << "modules_dirs: /single/path\n"; }
|
|
|
|
|
output.clear();
|
|
|
|
|
EXPECT_EQ(runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + fx.dir.string() + " daemon config set " + bad.string(),
|
|
|
|
|
&output, 5), 1) << "Output:\n" << output;
|
|
|
|
|
|
|
|
|
|
std::ifstream ifs(fx.daemonConfig());
|
|
|
|
|
const std::string body((std::istreambuf_iterator<char>(ifs)),
|
|
|
|
|
std::istreambuf_iterator<char>());
|
|
|
|
|
EXPECT_NE(body.find("insecure_tcp"), std::string::npos)
|
|
|
|
|
<< "The accepted config must survive a rejected one. It now reads:\n" << body;
|
|
|
|
|
EXPECT_EQ(body.find("modules_dirs"), std::string::npos)
|
|
|
|
|
<< "The rejected document must not have been applied. It now reads:\n" << body;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(CLITest, ClientConfigSet_TypeMismatchIsReportedNotFatal) {
|
|
|
|
|
// The client half of the same hazard, and the same guarantee.
|
|
|
|
|
ConfigFixture fx("clienttype",
|
|
|
|
|
"token_file: auto.json\n"
|
|
|
|
|
"daemon:\n"
|
|
|
|
|
" core_service:\n"
|
|
|
|
|
" transport: tcp\n"
|
|
|
|
|
" host: 127.0.0.1\n"
|
|
|
|
|
" port: \"6001\"\n");
|
|
|
|
|
|
|
|
|
|
std::string output;
|
|
|
|
|
int exitCode = runLogosctlWithTimeout(
|
|
|
|
|
"--config-dir " + fx.dir.string() + " client config set " + fx.doc.string(),
|
|
|
|
|
&output, 5);
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(exitCode, 1)
|
|
|
|
|
<< "A mistyped value must be reported (exit 1), not abort the process "
|
|
|
|
|
"(exit 134). Output:\n" << output;
|
|
|
|
|
EXPECT_NE(output.find("port"), std::string::npos)
|
|
|
|
|
<< "The error must name the offending key. Output:\n" << output;
|
|
|
|
|
EXPECT_FALSE(fs::exists(fx.clientConfig()))
|
|
|
|
|
<< "A document that cannot be understood must not be written.";
|
2026-06-15 10:52:45 -04:00
|
|
|
}
|