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
This commit is contained in:
+7
-1
@@ -22,7 +22,13 @@ configure_file(output : 'config.h', configuration : conf_data)
|
||||
# own ENABLE_TESTS-guarded runtime message in self_test.cpp.
|
||||
test_sources = []
|
||||
if get_option('enable_tests')
|
||||
test_sources = ['tests/integration/test_root_networking.cpp']
|
||||
test_sources = [
|
||||
'tests/unit/test_port_forward.cpp',
|
||||
'tests/unit/test_env_spec.cpp',
|
||||
'tests/unit/test_network_subnet.cpp',
|
||||
'tests/unit/test_cli_args.cpp',
|
||||
'tests/integration/test_root_networking.cpp',
|
||||
]
|
||||
endif
|
||||
|
||||
slocker_lite = executable('slocker-lite',
|
||||
|
||||
@@ -336,6 +336,23 @@ bool apply_log_level(std::string_view name) {
|
||||
}
|
||||
|
||||
std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
|
||||
// getopt_long's own scanning position is process-global state, not
|
||||
// reset automatically -- a real invocation of this binary only ever
|
||||
// calls parse_args() once, so this has never mattered before, but the
|
||||
// [unit] test suite (tests/unit/test_cli_args.cpp) calls it repeatedly
|
||||
// against different synthetic argv's within the same process. Plain
|
||||
// `optind = 1` (confirmed insufficient by testing, not assumed: a
|
||||
// -h/-V call returns out of the getopt_long loop early, via its own
|
||||
// `return 0` case, before that call ever got a chance to finish its
|
||||
// scan and let getopt_long null out its own private `nextchar`
|
||||
// pointer -- the *next* parse_args() call, even with optind reset,
|
||||
// then resumed scanning through that stale pointer into what was the
|
||||
// *previous* call's now-destroyed argv strings, misparsing its own
|
||||
// fresh argv as a result) isn't enough on its own. glibc's getopt
|
||||
// documents `optind = 0` specifically as the "fully reinitialize,
|
||||
// including private state, before rescanning a new argv" signal, so
|
||||
// that's what actually fixes it.
|
||||
optind = 0;
|
||||
opterr = 0;
|
||||
int opt;
|
||||
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:l:v:i:x:Dwn:p:", long_options.data(), nullptr)) != -1) {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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 parse_args() (cli_args.h) against synthetic argv's --
|
||||
// pure argument parsing, no mounting/running/side effects. Each call goes
|
||||
// through the real getopt_long()-based parser exactly as main() does.
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "cli_args.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Builds a synthetic argv (argv[0] is always "slocker-lite", matching this
|
||||
// project's own real binary name -- parse_args() only ever uses it for
|
||||
// print_usage()'s own diagnostic text) and calls parse_args(). The
|
||||
// std::vector<std::string> is kept alive by the caller for as long as the
|
||||
// returned char* vector is used, since parse_args() doesn't copy argv's
|
||||
// own strings out (ParsedArgs::command/test_args are the only things it
|
||||
// keeps, and those *are* copies).
|
||||
struct ParseResult {
|
||||
std::optional<int> exit_code;
|
||||
ParsedArgs args;
|
||||
};
|
||||
|
||||
ParseResult run_parse(const std::vector<std::string>& args) {
|
||||
std::vector<std::string> owned = {"slocker-lite"};
|
||||
owned.insert(owned.end(), args.begin(), args.end());
|
||||
|
||||
std::vector<char*> argv;
|
||||
argv.reserve(owned.size());
|
||||
for (auto& a : owned) {
|
||||
argv.push_back(a.data());
|
||||
}
|
||||
|
||||
ParseResult result;
|
||||
result.exit_code = parse_args(static_cast<int>(argv.size()), argv.data(), result.args);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("parse_args: -m <image.tar> selects Mode::mount", "[unit]") {
|
||||
auto result = run_parse({"-m", "image.tar"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
CHECK(result.args.mode == Mode::mount);
|
||||
CHECK(result.args.mode_arg == "image.tar");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -h returns exit code 0 immediately", "[unit]") {
|
||||
auto result = run_parse({"-h"});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -V returns exit code 0 immediately", "[unit]") {
|
||||
auto result = run_parse({"-V"});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: no mode at all is a parse error", "[unit]") {
|
||||
auto result = run_parse({});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: two actions on the same command line is a parse error", "[unit]") {
|
||||
auto result = run_parse({"-m", "a.tar", "-l", "dir"});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: --group without --user is a parse error", "[unit]") {
|
||||
auto result = run_parse({"-r", "a.tar", "--group", "mygroup", "--", "true"});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -r captures the trailing command after --", "[unit]") {
|
||||
auto result = run_parse({"-r", "a.tar", "--", "sh", "-c", "echo hi"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
CHECK(result.args.mode == Mode::run);
|
||||
REQUIRE(result.args.command.size() == 3);
|
||||
CHECK(result.args.command[0] == "sh");
|
||||
CHECK(result.args.command[1] == "-c");
|
||||
CHECK(result.args.command[2] == "echo hi");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -x <pid> -- <command> selects Mode::exec with a parsed pid", "[unit]") {
|
||||
auto result = run_parse({"-x", "1234", "--", "ls", "-la"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
CHECK(result.args.mode == Mode::exec);
|
||||
REQUIRE(result.args.exec_pid.has_value());
|
||||
CHECK(*result.args.exec_pid == 1234);
|
||||
REQUIRE(result.args.command.size() == 2);
|
||||
CHECK(result.args.command[0] == "ls");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -x with a non-numeric pid is a parse error", "[unit]") {
|
||||
auto result = run_parse({"-x", "notapid", "--", "ls"});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -x with no trailing command is a parse error", "[unit]") {
|
||||
auto result = run_parse({"-x", "1234"});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: leftover positional args are rejected for a mode that doesn't take one", "[unit]") {
|
||||
auto result = run_parse({"-l", "dir", "extra"});
|
||||
REQUIRE(result.exit_code.has_value());
|
||||
CHECK(*result.exit_code == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: bare -t has an empty test_args and no leftover-args error", "[unit]") {
|
||||
auto result = run_parse({"-t"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
CHECK(result.args.mode == Mode::test);
|
||||
CHECK(result.args.test_args.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -t -- <catch options> captures everything after -- verbatim", "[unit]") {
|
||||
auto result = run_parse({"-t", "--", "[unit]", "--reporter=compact"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
CHECK(result.args.mode == Mode::test);
|
||||
REQUIRE(result.args.test_args.size() == 2);
|
||||
CHECK(result.args.test_args[0] == "[unit]");
|
||||
CHECK(result.args.test_args[1] == "--reporter=compact");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -t <tag-expression> works without a '--' since it doesn't look like an option", "[unit]") {
|
||||
auto result = run_parse({"-t", "[unit]"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
CHECK(result.args.mode == Mode::test);
|
||||
REQUIRE(result.args.test_args.size() == 1);
|
||||
CHECK(result.args.test_args[0] == "[unit]");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: repeated -n accumulates network_specs in order", "[unit]") {
|
||||
auto result = run_parse({"-r", "a.tar", "-n", "net1", "-n", "net2", "--", "true"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
REQUIRE(result.args.network_specs.size() == 2);
|
||||
CHECK(result.args.network_specs[0] == "net1");
|
||||
CHECK(result.args.network_specs[1] == "net2");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: -v with -r accumulates volume_specs as (spec, container-path) pairs", "[unit]") {
|
||||
auto result = run_parse({"-r", "a.tar", "-v", "myvol", "/data", "--", "true"});
|
||||
REQUIRE_FALSE(result.exit_code.has_value());
|
||||
REQUIRE(result.args.volume_specs.size() == 1);
|
||||
CHECK(result.args.volume_specs[0].first == "myvol");
|
||||
CHECK(result.args.volume_specs[0].second == "/data");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_args: --kill requires a numeric pid", "[unit]") {
|
||||
auto ok = run_parse({"--kill", "5678"});
|
||||
REQUIRE_FALSE(ok.exit_code.has_value());
|
||||
CHECK(ok.args.mode == Mode::kill);
|
||||
REQUIRE(ok.args.kill_pid.has_value());
|
||||
CHECK(*ok.args.kill_pid == 5678);
|
||||
|
||||
auto bad = run_parse({"--kill", "notapid"});
|
||||
REQUIRE(bad.exit_code.has_value());
|
||||
CHECK(*bad.exit_code == 1);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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());
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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 network_subnet.h's pure CIDR arithmetic -- no kernel/ip
|
||||
// calls of its own, so entirely self-contained.
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "network_subnet.h"
|
||||
|
||||
TEST_CASE("is_valid_network_name", "[unit]") {
|
||||
CHECK(is_valid_network_name("mynet"));
|
||||
CHECK_FALSE(is_valid_network_name(""));
|
||||
CHECK_FALSE(is_valid_network_name("has:colon"));
|
||||
}
|
||||
|
||||
TEST_CASE("is_valid_ipv4_cidr", "[unit]") {
|
||||
CHECK(is_valid_ipv4_cidr("10.168.0.0/24"));
|
||||
CHECK(is_valid_ipv4_cidr("0.0.0.0/0"));
|
||||
CHECK(is_valid_ipv4_cidr("255.255.255.255/32"));
|
||||
CHECK_FALSE(is_valid_ipv4_cidr("10.168.0.0/33"));
|
||||
CHECK_FALSE(is_valid_ipv4_cidr("not-an-ip/24"));
|
||||
CHECK_FALSE(is_valid_ipv4_cidr("10.168.0.0")); // no prefix length
|
||||
CHECK_FALSE(is_valid_ipv4_cidr("fdf0::1/64")); // IPv6, not IPv4
|
||||
}
|
||||
|
||||
TEST_CASE("is_valid_ipv6_cidr", "[unit]") {
|
||||
CHECK(is_valid_ipv6_cidr("fdf0:f243:f06f:168::/64"));
|
||||
CHECK(is_valid_ipv6_cidr("::/0"));
|
||||
CHECK_FALSE(is_valid_ipv6_cidr("fdf0::/129"));
|
||||
CHECK_FALSE(is_valid_ipv6_cidr("10.168.0.0/24")); // IPv4, not IPv6
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_cidrs_overlap: identical subnets overlap", "[unit]") {
|
||||
CHECK(ipv4_cidrs_overlap("10.168.0.0/24", "10.168.0.0/24"));
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_cidrs_overlap: disjoint subnets don't overlap", "[unit]") {
|
||||
CHECK_FALSE(ipv4_cidrs_overlap("10.168.0.0/24", "10.168.1.0/24"));
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_cidrs_overlap: a subnet containing another overlaps, either direction", "[unit]") {
|
||||
CHECK(ipv4_cidrs_overlap("10.168.0.0/16", "10.168.5.0/24"));
|
||||
CHECK(ipv4_cidrs_overlap("10.168.5.0/24", "10.168.0.0/16"));
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_cidrs_overlap: an unparseable CIDR is treated as no overlap", "[unit]") {
|
||||
CHECK_FALSE(ipv4_cidrs_overlap("garbage", "10.168.0.0/24"));
|
||||
}
|
||||
|
||||
TEST_CASE("ipv6_cidrs_overlap: same shape as the IPv4 case", "[unit]") {
|
||||
CHECK(ipv6_cidrs_overlap("fdf0:f243:f06f:168::/64", "fdf0:f243:f06f:168::/64"));
|
||||
CHECK_FALSE(ipv6_cidrs_overlap("fdf0:f243:f06f:168::/64", "fdf0:f243:f06f:169::/64"));
|
||||
CHECK(ipv6_cidrs_overlap("fdf0:f243:f06f::/48", "fdf0:f243:f06f:168::/64"));
|
||||
}
|
||||
|
||||
TEST_CASE("allocate_ipv4_subnet: first block when nothing exists yet", "[unit]") {
|
||||
auto subnet = allocate_ipv4_subnet({});
|
||||
REQUIRE(subnet.has_value());
|
||||
CHECK(*subnet == "10.168.0.0/24");
|
||||
}
|
||||
|
||||
TEST_CASE("allocate_ipv4_subnet: skips a subnet already in use", "[unit]") {
|
||||
std::vector<NetworkEntry> existing = {{"taken", NetworkKind::extern_, "10.168.0.0/24", true, "", true}};
|
||||
auto subnet = allocate_ipv4_subnet(existing);
|
||||
REQUIRE(subnet.has_value());
|
||||
CHECK(*subnet == "10.168.1.0/24");
|
||||
}
|
||||
|
||||
TEST_CASE("allocate_ipv4_subnet: also respects a manually --subnet-overridden entry", "[unit]") {
|
||||
// Not itself an exact 10.168.<n>.0/24 block (a real --subnet override
|
||||
// need not be), but still overlaps n=0's candidate and must be
|
||||
// skipped, via ipv4_cidrs_overlap() -- not just an exact-match check.
|
||||
std::vector<NetworkEntry> existing = {{"manual", NetworkKind::intern, "10.168.0.128/25", true, "", true}};
|
||||
auto subnet = allocate_ipv4_subnet(existing);
|
||||
REQUIRE(subnet.has_value());
|
||||
CHECK(*subnet == "10.168.1.0/24");
|
||||
}
|
||||
|
||||
TEST_CASE("allocate_ipv6_subnet: first block when nothing exists yet", "[unit]") {
|
||||
auto subnet = allocate_ipv6_subnet({});
|
||||
REQUIRE(subnet.has_value());
|
||||
CHECK(*subnet == "fdf0:f243:f06f:168::/64");
|
||||
}
|
||||
|
||||
TEST_CASE("allocate_ipv6_subnet: skips a subnet already in use", "[unit]") {
|
||||
std::vector<NetworkEntry> existing = {
|
||||
{"taken", NetworkKind::extern_, "10.168.0.0/24", true, "fdf0:f243:f06f:168::/64", true}};
|
||||
auto subnet = allocate_ipv6_subnet(existing);
|
||||
REQUIRE(subnet.has_value());
|
||||
CHECK(*subnet == "fdf0:f243:f06f:169::/64");
|
||||
}
|
||||
|
||||
TEST_CASE("allocate_ipv6_subnet: an ipv6-disabled existing entry doesn't block reuse of its subnet6", "[unit]") {
|
||||
// A network created with --no-ipv6 has ipv6=false and an empty subnet6
|
||||
// -- nothing to collide with, so this is really just confirming
|
||||
// allocate_ipv6_subnet() doesn't crash/misbehave on such an entry.
|
||||
std::vector<NetworkEntry> existing = {{"v4only", NetworkKind::extern_, "10.168.0.0/24", false, "", true}};
|
||||
auto subnet = allocate_ipv6_subnet(existing);
|
||||
REQUIRE(subnet.has_value());
|
||||
CHECK(*subnet == "fdf0:f243:f06f:168::/64");
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_gateway_address: masks down to the network address and sets the host bits to .1", "[unit]") {
|
||||
CHECK(ipv4_gateway_address("10.168.0.0/24") == "10.168.0.1/24");
|
||||
// Not already a canonical network address -- still masks down first.
|
||||
CHECK(ipv4_gateway_address("10.168.0.5/24") == "10.168.0.1/24");
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_gateway_address: nullopt on an unparseable CIDR", "[unit]") {
|
||||
CHECK_FALSE(ipv4_gateway_address("garbage").has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("ipv6_gateway_address: masks down and sets the host bits to ::1", "[unit]") {
|
||||
CHECK(ipv6_gateway_address("fdf0:f243:f06f:168::/64") == "fdf0:f243:f06f:168::1/64");
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_host_address: n=1 matches the gateway address", "[unit]") {
|
||||
CHECK(ipv4_host_address("10.168.0.0/24", 1) == ipv4_gateway_address("10.168.0.0/24"));
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_host_address: n=2, 3 are distinct successive addresses", "[unit]") {
|
||||
CHECK(ipv4_host_address("10.168.0.0/24", 2) == "10.168.0.2/24");
|
||||
CHECK(ipv4_host_address("10.168.0.0/24", 3) == "10.168.0.3/24");
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_host_address: carries across an octet boundary", "[unit]") {
|
||||
CHECK(ipv4_host_address("10.168.0.0/16", 256) == "10.168.1.0/16");
|
||||
}
|
||||
|
||||
TEST_CASE("ipv4_host_address: nullopt when n doesn't fit the host-bit width", "[unit]") {
|
||||
// A /24 has 8 host bits -- 256 hosts (0..255), so n=300 doesn't fit.
|
||||
CHECK_FALSE(ipv4_host_address("10.168.0.0/24", 300).has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("ipv6_host_address: n=2 is a distinct address from the gateway", "[unit]") {
|
||||
CHECK(ipv6_host_address("fdf0:f243:f06f:168::/64", 2) == "fdf0:f243:f06f:168::2/64");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// 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 parse_port_forward_spec() (port_forward.h) -- pure
|
||||
// syntax/range parsing, no networks/processes involved.
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "port_forward.h"
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: bare host:container defaults to tcp, no network", "[unit]") {
|
||||
auto spec = parse_port_forward_spec("8080:80");
|
||||
REQUIRE(spec.has_value());
|
||||
CHECK_FALSE(spec->network.has_value());
|
||||
CHECK(spec->host_port == 8080);
|
||||
CHECK(spec->container_port == 80);
|
||||
CHECK(spec->protocol == PortForwardProtocol::tcp);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: network:host:container", "[unit]") {
|
||||
auto spec = parse_port_forward_spec("mynet:8080:80");
|
||||
REQUIRE(spec.has_value());
|
||||
REQUIRE(spec->network.has_value());
|
||||
CHECK(*spec->network == "mynet");
|
||||
CHECK(spec->host_port == 8080);
|
||||
CHECK(spec->container_port == 80);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: explicit /tcp suffix", "[unit]") {
|
||||
auto spec = parse_port_forward_spec("8080:80/tcp");
|
||||
REQUIRE(spec.has_value());
|
||||
CHECK(spec->container_port == 80);
|
||||
CHECK(spec->protocol == PortForwardProtocol::tcp);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: /udp suffix", "[unit]") {
|
||||
auto spec = parse_port_forward_spec("8080:80/udp");
|
||||
REQUIRE(spec.has_value());
|
||||
CHECK(spec->host_port == 8080);
|
||||
CHECK(spec->container_port == 80);
|
||||
CHECK(spec->protocol == PortForwardProtocol::udp);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: network + /udp suffix combined", "[unit]") {
|
||||
auto spec = parse_port_forward_spec("mynet:8080:80/udp");
|
||||
REQUIRE(spec.has_value());
|
||||
REQUIRE(spec->network.has_value());
|
||||
CHECK(*spec->network == "mynet");
|
||||
CHECK(spec->protocol == PortForwardProtocol::udp);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: rejects an unrecognized protocol suffix", "[unit]") {
|
||||
CHECK_FALSE(parse_port_forward_spec("8080:80/xyz").has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: rejects out-of-range ports", "[unit]") {
|
||||
CHECK_FALSE(parse_port_forward_spec("0:80").has_value());
|
||||
CHECK_FALSE(parse_port_forward_spec("8080:0").has_value());
|
||||
CHECK_FALSE(parse_port_forward_spec("65536:80").has_value());
|
||||
CHECK_FALSE(parse_port_forward_spec("8080:65536").has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: rejects non-numeric ports", "[unit]") {
|
||||
CHECK_FALSE(parse_port_forward_spec("abc:80").has_value());
|
||||
CHECK_FALSE(parse_port_forward_spec("8080:abc").has_value());
|
||||
CHECK_FALSE(parse_port_forward_spec("80.5:80").has_value());
|
||||
}
|
||||
|
||||
TEST_CASE("parse_port_forward_spec: rejects malformed field counts", "[unit]") {
|
||||
CHECK_FALSE(parse_port_forward_spec("8080").has_value());
|
||||
CHECK_FALSE(parse_port_forward_spec("").has_value());
|
||||
CHECK_FALSE(parse_port_forward_spec("a:b:c:d").has_value());
|
||||
}
|
||||
Reference in New Issue
Block a user