Files
ceamac 42f9d36edf Add [unit] tests: port_forward, env_spec, network_subnet, cli_args
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
2026-09-04 11:23:24 +00:00

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());
}