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:
2026-09-04 09:45:59 +00:00
parent ec1ce505d2
commit 42f9d36edf
6 changed files with 583 additions and 1 deletions
+7 -1
View File
@@ -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',
+17
View File
@@ -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) {
+184
View File
@@ -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);
}
+138
View File
@@ -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());
}
+151
View File
@@ -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");
}
+86
View File
@@ -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());
}