42f9d36edf
One file per source area, exercising the pure/isolated parsing and CIDR- arithmetic functions already exposed via headers with no side effects -- parse_port_forward_spec() (protocol suffix parsing/validation, network resolution left to add_port_forward()), resolve_env_specs() (literal and --env-file parsing, ordering, error cases -- a small local RAII ScratchFile helper writes the --env-file fixtures under /tmp), network_subnet.h's CIDR validation/overlap/allocation/address-arithmetic functions, and parse_args() itself against synthetic argv's. Two real bugs found running parse_args() repeatedly in one process (never possible before -- a real invocation only ever calls it once), not assumed: 1. getopt_long's scanning position (`optind`) is process-global and never reset, so a second parse_args() call would silently resume scanning wherever the first one left off. Fixing this alone (optind = 1) wasn't enough on its own, either -- 2. -h/-V return out of the getopt_long loop early (their own `return 0` case), before a call ever completes its scan and lets getopt_long null out its own private `nextchar` pointer -- the *next* parse_args() call then resumed scanning through that stale pointer into the *previous* call's already-destroyed argv strings, misparsing its own fresh argv. glibc documents `optind = 0` (not 1) as the "fully reinitialize private state before rescanning a new argv" signal; switching to it fixed this for good, confirmed by 3 repeated runs each in both random and deterministic (--order lex) Catch2 ordering with zero flakiness either way. Neither bug could ever have surfaced in real usage (parse_args() is only ever called once per process from main()) -- purely a testability gap the new unit tests exposed, now fixed at the source rather than worked around in the test file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
139 lines
5.0 KiB
C++
139 lines
5.0 KiB
C++
// Copyright (C) 2026 Viorel Munteanu
|
|
//
|
|
// 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, write to the Free Software Foundation, Inc.,
|
|
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
|
|
// [unit] tests for resolve_env_specs() (env_spec.h). The --env-file case
|
|
// needs a real file on disk -- a tiny local RAII helper below writes one
|
|
// to a unique path under /tmp and removes it in its destructor, so a
|
|
// REQUIRE-triggered early return from within a TEST_CASE still cleans up
|
|
// (unlike the real host-state tests under tests/integration/, a stray
|
|
// leftover file here is harmless but there's no reason to leave one).
|
|
|
|
#include <unistd.h>
|
|
|
|
#include <cstdio>
|
|
#include <fstream>
|
|
#include <string>
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <fmt/core.h>
|
|
|
|
#include "env_spec.h"
|
|
|
|
namespace {
|
|
|
|
class ScratchFile {
|
|
public:
|
|
explicit ScratchFile(const std::string& content) : path_(fmt::format("/tmp/slocker-lite-test-env-{}", getpid())) {
|
|
std::ofstream out(path_);
|
|
out << content;
|
|
}
|
|
~ScratchFile() { std::remove(path_.c_str()); }
|
|
ScratchFile(const ScratchFile&) = delete;
|
|
ScratchFile& operator=(const ScratchFile&) = delete;
|
|
|
|
const std::string& path() const { return path_; }
|
|
|
|
private:
|
|
std::string path_;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("resolve_env_specs: a single literal --env", "[unit]") {
|
|
auto resolved = resolve_env_specs({{false, "FOO=bar"}});
|
|
REQUIRE(resolved.has_value());
|
|
REQUIRE(resolved->size() == 1);
|
|
CHECK((*resolved)[0].first == "FOO");
|
|
CHECK((*resolved)[0].second == "bar");
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: splits at the first '=' only, value may contain '='", "[unit]") {
|
|
auto resolved = resolve_env_specs({{false, "FOO=bar=baz"}});
|
|
REQUIRE(resolved.has_value());
|
|
REQUIRE(resolved->size() == 1);
|
|
CHECK((*resolved)[0].first == "FOO");
|
|
CHECK((*resolved)[0].second == "bar=baz");
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: later --env for the same key is kept (append-order, last wins by convention)",
|
|
"[unit]") {
|
|
auto resolved = resolve_env_specs({{false, "FOO=first"}, {false, "FOO=second"}});
|
|
REQUIRE(resolved.has_value());
|
|
REQUIRE(resolved->size() == 2);
|
|
CHECK((*resolved)[0].second == "first");
|
|
CHECK((*resolved)[1].second == "second");
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: rejects a literal with no '='", "[unit]") {
|
|
CHECK_FALSE(resolve_env_specs({{false, "NOEQUALS"}}).has_value());
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: rejects an empty key", "[unit]") {
|
|
CHECK_FALSE(resolve_env_specs({{false, "=value"}}).has_value());
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: rejects a missing --env-file", "[unit]") {
|
|
CHECK_FALSE(resolve_env_specs({{true, "/nonexistent/path/slocker-lite-test-env-file"}}).has_value());
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: --env-file skips blank lines and #-comments", "[unit]") {
|
|
ScratchFile file(
|
|
"# a comment\n"
|
|
"\n"
|
|
" \n"
|
|
"FOO=bar\n"
|
|
" # indented comment\n"
|
|
"BAZ=qux\n");
|
|
auto resolved = resolve_env_specs({{true, file.path()}});
|
|
REQUIRE(resolved.has_value());
|
|
REQUIRE(resolved->size() == 2);
|
|
CHECK((*resolved)[0].first == "FOO");
|
|
CHECK((*resolved)[0].second == "bar");
|
|
CHECK((*resolved)[1].first == "BAZ");
|
|
CHECK((*resolved)[1].second == "qux");
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: --env-file strips a trailing '\\r' (CRLF files)", "[unit]") {
|
|
ScratchFile file("FOO=bar\r\n");
|
|
auto resolved = resolve_env_specs({{true, file.path()}});
|
|
REQUIRE(resolved.has_value());
|
|
REQUIRE(resolved->size() == 1);
|
|
CHECK((*resolved)[0].second == "bar");
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: --env-file with a malformed line fails the whole file, not skip-and-warn", "[unit]") {
|
|
ScratchFile file(
|
|
"GOOD=1\n"
|
|
"NOEQUALS\n");
|
|
CHECK_FALSE(resolve_env_specs({{true, file.path()}}).has_value());
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: --env and --env-file interleave in exact command-line order", "[unit]") {
|
|
ScratchFile file("FOO=from-file\n");
|
|
auto resolved = resolve_env_specs({{false, "FOO=from-cli-1"}, {true, file.path()}, {false, "FOO=from-cli-2"}});
|
|
REQUIRE(resolved.has_value());
|
|
REQUIRE(resolved->size() == 3);
|
|
CHECK((*resolved)[0].second == "from-cli-1");
|
|
CHECK((*resolved)[1].second == "from-file");
|
|
CHECK((*resolved)[2].second == "from-cli-2");
|
|
}
|
|
|
|
TEST_CASE("resolve_env_specs: empty spec list resolves to an empty list", "[unit]") {
|
|
auto resolved = resolve_env_specs({});
|
|
REQUIRE(resolved.has_value());
|
|
CHECK(resolved->empty());
|
|
}
|