From 992571f3793b439dde591a585e2735cf46292694 Mon Sep 17 00:00:00 2001 From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com> Date: Mon, 25 May 2026 09:39:25 +0200 Subject: [PATCH 1/8] test(serverstats): add unit coverage for ConfigServer::ServerStats Covers init() GD-absent fallback, charts_html() layout with and without cc_lookups, and minmaxavg() observed through graphs_html() rendering (MIN/MAX/AVG accumulation and per-bucket isolation across HOUR/DAY/WEEK/ MONTH timeframes). --- .github/tests/unit/serverstats.t | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/tests/unit/serverstats.t diff --git a/.github/tests/unit/serverstats.t b/.github/tests/unit/serverstats.t new file mode 100644 index 0000000..75e1664 --- /dev/null +++ b/.github/tests/unit/serverstats.t @@ -0,0 +1,95 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use FindBin qw($Bin); +use Test::More; +use lib "$Bin/../lib"; + +use TestBootstrap (); +use ConfigServer::ServerStats; + +sub reset_serverstats_state { + delete $INC{'ConfigServer/ServerStats.pm'}; + require ConfigServer::ServerStats; + return; +} + +subtest 'init returns undef when GD::Graph::bars is unavailable' => sub { + my $loaded = eval { require GD::Graph::bars; 1 }; + + SKIP: { + skip 'GD::Graph::bars is installed in this environment', 1 if $loaded; + + my $result = ConfigServer::ServerStats::init(); + is($result, undef, 'init returns undef when GD::Graph backends cannot load'); + } +}; + +subtest 'charts_html assembles the standard four-image layout' => sub { + my $html = ConfigServer::ServerStats::charts_html(0, '/img/'); + + like($html, qr{
| Min:10\.00 | Max:30\.00 | Avg:60\.00}, 'HOUR cpu row reports MIN=10, MAX=30, and the cumulative AVG sum'); +}; + +subtest 'minmaxavg keeps each (graph, name) bucket isolated' => sub { + reset_serverstats_state(); + + ConfigServer::ServerStats::minmaxavg('HOUR', '1cpu', 10); + ConfigServer::ServerStats::minmaxavg('HOUR', '1cpu', 20); + ConfigServer::ServerStats::minmaxavg('HOUR', '2load', 5); + ConfigServer::ServerStats::minmaxavg('DAY', '1cpu', 100); + ConfigServer::ServerStats::minmaxavg('WEEK', '3disk', 99); + ConfigServer::ServerStats::minmaxavg('MONTH', '4mem', 512); + + my $html = ConfigServer::ServerStats::graphs_html('/g/'); + + like($html, qr{cpu | Min:10\.00 | Max:20\.00}, 'HOUR cpu bucket shows only the HOUR samples'); + like($html, qr{load | Min:5\.00 | Max:5\.00}, 'HOUR load bucket is independent of cpu'); + like($html, qr{cpu | Min:100\.00 | Max:100\.00}, 'DAY cpu bucket is independent of HOUR cpu bucket'); + like($html, qr{disk | Min:99\.00}, 'WEEK bucket shows the WEEK sample only'); + like($html, qr{mem | Min:512\.00}, 'MONTH bucket shows the MONTH sample only');
+};
+
+subtest 'graphs_html targets the configured image directory for every timeframe' => sub {
+ reset_serverstats_state();
+
+ ConfigServer::ServerStats::minmaxavg('HOUR', '1x', 1);
+ ConfigServer::ServerStats::minmaxavg('DAY', '1x', 1);
+ ConfigServer::ServerStats::minmaxavg('WEEK', '1x', 1);
+ ConfigServer::ServerStats::minmaxavg('MONTH', '1x', 1);
+
+ my $html = ConfigServer::ServerStats::graphs_html('/cdn/');
+
+ like($html, qr{src='/cdn/lfd_systemhour\.gif\?text=\d+}, 'HOUR image src uses the supplied imgdir');
+ like($html, qr{src='/cdn/lfd_systemday\.gif\?text=\d+}, 'DAY image src uses the supplied imgdir');
+ like($html, qr{src='/cdn/lfd_systemweek\.gif\?text=\d+}, 'WEEK image src uses the supplied imgdir');
+ like($html, qr{src='/cdn/lfd_systemmonth\.gif\?text=\d+}, 'MONTH image src uses the supplied imgdir');
+};
+
+done_testing;
From f08956aac6c6df904e79845f9d5c5c507f9881de Mon Sep 17 00:00:00 2001
From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com>
Date: Mon, 25 May 2026 09:40:34 +0200
Subject: [PATCH 2/8] test(cloudflare): add unit coverage for
ConfigServer::CloudFlare
Covers checktarget() target classification (country/ip_range/ip), getscope()
parsing of DOMAIN/DISABLE/ANY directives in csf.cloudflare, and the LWP
interaction surface of block(), whitelist(), and remove() (success body,
error status-line surfacing, and the empty-id remove branch).
---
.github/tests/unit/cloudflare.t | 162 ++++++++++++++++++++++++++++++++
1 file changed, 162 insertions(+)
create mode 100644 .github/tests/unit/cloudflare.t
diff --git a/.github/tests/unit/cloudflare.t b/.github/tests/unit/cloudflare.t
new file mode 100644
index 0000000..4ab4dae
--- /dev/null
+++ b/.github/tests/unit/cloudflare.t
@@ -0,0 +1,162 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+use TestBootstrap ();
+
+{
+ package Local::FakeCFResponse;
+
+ sub new { my ($class, %args) = @_; return bless \%args, $class }
+ sub is_success { return $_[0]->{is_success} }
+ sub content { return $_[0]->{content} }
+ sub status_line { return $_[0]->{status_line} // 'fake-status' }
+}
+
+sub load_cloudflare {
+ my (%config) = @_;
+ $config{DEBUG} //= 0;
+ $config{URLGET} //= 1;
+ $config{CF_BLOCK} //= 'block';
+ $config{CF_CPANEL} //= 0;
+ TestBootstrap::reload_module_with_config('ConfigServer::CloudFlare', \%config);
+ return;
+}
+
+subtest 'checktarget classifies countries, CIDR ranges, and bare IPs' => sub {
+ load_cloudflare();
+
+ is(ConfigServer::CloudFlare::checktarget('US'), 'country', 'two-letter token is treated as a country code');
+ is(ConfigServer::CloudFlare::checktarget('192.0.2.0/24'), 'ip_range', '/24 CIDR is treated as an ip_range target');
+ is(ConfigServer::CloudFlare::checktarget('192.0.0.0/16'), 'ip_range', '/16 CIDR is treated as an ip_range target');
+ is(ConfigServer::CloudFlare::checktarget('192.0.2.10'), 'ip', 'plain IPv4 falls through to the ip target');
+ is(ConfigServer::CloudFlare::checktarget('2001:db8::1'), 'ip', 'plain IPv6 falls through to the ip target');
+};
+
+subtest 'getscope parses DOMAIN, DISABLE, and ANY directives from csf.cloudflare' => sub {
+ load_cloudflare(CF_CPANEL => 0);
+
+ no warnings qw(redefine once);
+ local *ConfigServer::CloudFlare::slurp = sub {
+ return (
+ '# example csf.cloudflare contents',
+ 'DOMAIN:example.com:USER:alice:ACCOUNT:alice@example.com:APIKEY:key-alice',
+ 'DOMAIN:any:USER:bob:ACCOUNT:bob@example.com:APIKEY:key-bob',
+ 'DISABLE:carol',
+ 'ANY:bob',
+ '',
+ );
+ };
+
+ my $scope = ConfigServer::CloudFlare::getscope();
+
+ is($scope->{domain}{'example.com'}{user}, 'alice', 'DOMAIN line records the owning user');
+ is($scope->{domain}{'example.com'}{account}, 'alice@example.com', 'DOMAIN line records the cloudflare account');
+ is($scope->{domain}{'example.com'}{apikey}, 'key-alice', 'DOMAIN line records the cloudflare API key');
+ is($scope->{user}{alice}{domain}{'example.com'}, 'example.com', 'user->domain mapping is populated from DOMAIN lines');
+ is($scope->{user}{bob}{any}, 1, 'a DOMAIN line with the special "any" name flags the user as catch-all');
+};
+
+subtest 'block POSTs JSON-encoded rule body and returns the new rule id on success' => sub {
+ load_cloudflare(CF_BLOCK => 'block');
+
+ my %posted;
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::post = sub {
+ my ($self, $url, %rest) = @_;
+ $posted{url} = $url;
+ $posted{content} = $rest{Content};
+ return Local::FakeCFResponse->new(
+ is_success => 1,
+ content => '{"result":{"id":"rule-block-1"}}',
+ );
+ };
+
+ my ($id, $status) = ConfigServer::CloudFlare::block('192.0.2.10');
+
+ is($id, 'rule-block-1', 'success path returns the new rule id from the parsed JSON response');
+ is($status, 'CloudFlare: block ip 192.0.2.10', 'success path returns a human-readable status string');
+ is($posted{url}, 'https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules', 'POST hits the access_rules endpoint');
+ like($posted{content}, qr/"target"\s*:\s*"ip"/, 'JSON body declares the ip target for a bare IPv4');
+ like($posted{content}, qr/"value"\s*:\s*"192\.0\.2\.10"/, 'JSON body carries the requested IP value');
+ like($posted{content}, qr/"mode"\s*:\s*"block"/, 'JSON body propagates the configured CF_BLOCK mode');
+};
+
+subtest 'block reports the status line when the upstream request fails' => sub {
+ load_cloudflare(CF_BLOCK => 'block');
+
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::post = sub {
+ return Local::FakeCFResponse->new(
+ is_success => 0,
+ content => '{"errors":[{"code":1000}]}',
+ status_line => '500 Internal Server Error',
+ );
+ };
+
+ my @result = ConfigServer::CloudFlare::block('192.0.2.10');
+
+ is(scalar @result, 1, 'failure path returns a single error string instead of (id, status)');
+ is($result[0], 'CloudFlare: [192.0.2.10] block failed: 500 Internal Server Error', 'error string includes the IP and the upstream status line');
+};
+
+subtest 'whitelist tags the rule with the whitelist mode and notes' => sub {
+ load_cloudflare();
+
+ my $body;
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::post = sub {
+ my ($self, $url, %rest) = @_;
+ $body = $rest{Content};
+ return Local::FakeCFResponse->new(
+ is_success => 1,
+ content => '{"result":{"id":"rule-allow-1"}}',
+ );
+ };
+
+ my ($id, $status) = ConfigServer::CloudFlare::whitelist('US');
+
+ is($id, 'rule-allow-1', 'whitelist returns the new rule id on success');
+ is($status, 'CloudFlare: whitelisted country US', 'whitelist status string includes the target classification');
+ like($body, qr/"mode"\s*:\s*"whitelist"/, 'whitelist body sets mode=whitelist regardless of CF_BLOCK');
+ like($body, qr/"target"\s*:\s*"country"/, 'whitelist body classifies a two-letter token as country');
+};
+
+subtest 'remove returns an id-not-found error when no id is supplied and lookup is empty' => sub {
+ load_cloudflare();
+
+ no warnings qw(redefine once);
+ local *ConfigServer::CloudFlare::getid = sub { return '' };
+
+ my $status = ConfigServer::CloudFlare::remove('192.0.2.10', 'block', '');
+
+ is($status, 'CloudFlare: [192.0.2.10] remove failed: id not found', 'missing rule id surfaces a clear error from remove()');
+};
+
+subtest 'remove issues DELETE and reports success when an explicit id is given' => sub {
+ load_cloudflare();
+
+ my %deleted;
+ no warnings qw(redefine once);
+ local *LWP::UserAgent::new = sub { return bless {}, shift };
+ local *LWP::UserAgent::delete = sub {
+ my ($self, $url, %rest) = @_;
+ $deleted{url} = $url;
+ return Local::FakeCFResponse->new(is_success => 1, content => '{}');
+ };
+
+ my $status = ConfigServer::CloudFlare::remove('192.0.2.10', 'block', 'rule-123');
+
+ is($status, 'CloudFlare: removed ip 192.0.2.10', 'remove returns a human-readable success status');
+ is($deleted{url}, 'https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules/rule-123', 'DELETE targets the rule id endpoint');
+};
+
+done_testing;
From 53aed91b54014953507aa3d2e550e21816738807 Mon Sep 17 00:00:00 2001
From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com>
Date: Mon, 25 May 2026 09:41:35 +0200
Subject: [PATCH 3/8] test(messenger): add unit coverage for
ConfigServer::Messenger
Covers init() returning a blessed v2 instance without touching the v1/v3
side effects, and _read_request_line() against a fake client (plain LF
termination, CRLF stripping, and MAX_LINE_LENGTH cap behavior).
---
.github/tests/unit/messenger.t | 84 ++++++++++++++++++++++++++++++++++
1 file changed, 84 insertions(+)
create mode 100644 .github/tests/unit/messenger.t
diff --git a/.github/tests/unit/messenger.t b/.github/tests/unit/messenger.t
new file mode 100644
index 0000000..0cd0017
--- /dev/null
+++ b/.github/tests/unit/messenger.t
@@ -0,0 +1,84 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+use TestBootstrap ();
+
+{
+ package Local::FakeMessengerClient;
+
+ sub new {
+ my ($class, $payload) = @_;
+ return bless { buf => [ split //, $payload ] }, $class;
+ }
+
+ sub read {
+ my $self = shift;
+ # Caller passes: $client->read($char, 1)
+ # $_[0] aliases the caller's $char, $_[1] aliases the length argument.
+ if (@{ $self->{buf} }) {
+ $_[0] = shift @{ $self->{buf} };
+ } else {
+ $_[0] = "\n";
+ }
+ return 1;
+ }
+}
+
+sub load_messenger {
+ my (%config) = @_;
+ $config{IPV6} //= 0;
+ $config{MESSENGER6} //= 0;
+ $config{RECAPTCHA_NAT} //= '';
+ $config{DEBUG} //= 0;
+ TestBootstrap::reload_module_with_config('ConfigServer::Messenger', \%config);
+ return;
+}
+
+subtest 'init returns a blessed instance for version 2 without touching ssl directories' => sub {
+ load_messenger();
+
+ my $obj = ConfigServer::Messenger->init(2);
+
+ isa_ok($obj, 'ConfigServer::Messenger', 'init returns a Messenger object for the v2 (TEXT) flavor');
+ can_ok($obj, 'start');
+};
+
+subtest '_read_request_line returns an LF-terminated request line without the trailing newline' => sub {
+ load_messenger();
+
+ my $client = Local::FakeMessengerClient->new("GET / HTTP/1.0\n");
+ my $line = ConfigServer::Messenger::_read_request_line($client);
+
+ is($line, 'GET / HTTP/1.0', 'trailing LF is chomped off the returned line');
+};
+
+subtest '_read_request_line strips a trailing CR when the request uses CRLF' => sub {
+ load_messenger();
+
+ my $client = Local::FakeMessengerClient->new("GET /resource HTTP/1.1\r\n");
+ my $line = ConfigServer::Messenger::_read_request_line($client);
+
+ is($line, 'GET /resource HTTP/1.1', 'CRLF terminator is reduced to a bare request line');
+};
+
+subtest '_read_request_line stops reading once MAX_LINE_LENGTH bytes have been consumed' => sub {
+ load_messenger();
+
+ my $payload = ('A' x 5000) . "\n";
+ my $client = Local::FakeMessengerClient->new($payload);
+ my $line = ConfigServer::Messenger::_read_request_line($client);
+
+ ok(length($line) > ConfigServer::Messenger::MAX_LINE_LENGTH(),
+ 'overlong request lines exit the read loop without consuming the entire payload');
+ ok(length($line) <= ConfigServer::Messenger::MAX_LINE_LENGTH() + 1,
+ 'read stops within one byte of the documented MAX_LINE_LENGTH cap');
+ unlike($line, qr/\n/, 'returned line never contains an embedded newline');
+};
+
+done_testing;
From 1b1dfdcd360019fe6ae2eeb9fc14d3b31c5dd76b Mon Sep 17 00:00:00 2001
From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com>
Date: Mon, 25 May 2026 09:45:04 +0200
Subject: [PATCH 4/8] test(rblcheck): add unit coverage for
ConfigServer::RBLCheck
Covers report()'s externally observable behavior under non-verbose mode:
empty-IP set returns no failures and only the endoutput marker, a fresh
PUBLIC IP gets a "New ... (PUBLIC)" / "Not Checked" placeholder when no
cached result exists, and non-PUBLIC IPs are silently skipped. Mocks
ConfigServer::GetEthDev, slurp, and rbllookup; reloads the module between
calls to reset its file-scope output buffer.
---
.github/tests/unit/rblcheck.t | 102 ++++++++++++++++++++++++++++++++++
1 file changed, 102 insertions(+)
create mode 100644 .github/tests/unit/rblcheck.t
diff --git a/.github/tests/unit/rblcheck.t b/.github/tests/unit/rblcheck.t
new file mode 100644
index 0000000..0415e85
--- /dev/null
+++ b/.github/tests/unit/rblcheck.t
@@ -0,0 +1,102 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+
+use FindBin qw($Bin);
+use Test::More;
+use lib "$Bin/../lib";
+
+use TestBootstrap ();
+
+# RBLCheck pulls in GetIPs and RBLLookup, which both validate IPTABLES/HOST in
+# Config::loadconfig at import time. Load the whole chain once with a benign
+# mocked config before any other test code touches the module.
+BEGIN {
+ TestBootstrap::reload_module_with_config(
+ 'ConfigServer::RBLCheck',
+ { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 },
+ also_delete => [qw(ConfigServer::GetIPs ConfigServer::RBLLookup)],
+ );
+}
+
+{
+ package Local::FakeEthDev;
+
+ sub new {
+ my ($class, %args) = @_;
+ return bless { ipv4 => $args{ipv4} || {} }, $class;
+ }
+
+ sub ipv4 { return %{ $_[0]->{ipv4} } }
+ sub ipv6 { return () }
+ sub ifaces { return () }
+}
+
+sub run_report_with_ips {
+ my ($ipv4) = @_;
+
+ # RBLCheck caches $output as a file-scope lexical that persists across
+ # report() calls. Reload the module so each test sees a fresh output buffer.
+ TestBootstrap::reload_module_with_config(
+ 'ConfigServer::RBLCheck',
+ { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 },
+ );
+
+ no warnings qw(redefine once);
+ local *ConfigServer::GetEthDev::new = sub {
+ return Local::FakeEthDev->new(ipv4 => $ipv4);
+ };
+ local *ConfigServer::RBLCheck::slurp = sub { return () };
+ local *ConfigServer::RBLCheck::rbllookup = sub { return ('', '') };
+
+ my @result;
+ TestBootstrap::with_mock_config(
+ { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 },
+ sub { @result = ConfigServer::RBLCheck::report(0, 0, 0) },
+ );
+ return @result;
+}
+
+subtest 'module loads cleanly and exposes the public reporting surface' => sub {
+ can_ok('ConfigServer::RBLCheck', qw(report addline addtitle startoutput endoutput getethdev));
+};
+
+subtest 'report() returns zero failures and a minimal output when no IPs are discovered' => sub {
+ my ($failures, $output) = run_report_with_ips({});
+
+ is($failures, 0, 'no IPs means no failures are recorded');
+ ok(defined $output && length $output, 'report still emits the endoutput marker even with no IPs');
+ like($output, qr/ /, 'output contains the closing from endoutput'); + unlike($output, qr/Not Checked/, 'no IPs means no per-IP "Not Checked" placeholder is added'); +}; + +subtest 'report() marks a fresh PUBLIC IP as Not Checked in non-verbose mode' => sub { + my $ip = '8.8.8.8'; + + SKIP: { + skip "cached RBL state for $ip already exists on this host", 3 + if -e "/var/lib/csf/$ip.rbls"; + + eval { require Net::IP; 1 } or skip 'Net::IP is not available', 3; + my $type = eval { Net::IP->new($ip)->iptype }; + skip "Net::IP classifies $ip as $type (expected PUBLIC)", 3 + unless defined $type && $type eq 'PUBLIC'; + + my ($failures, $output) = run_report_with_ips({ $ip => 1 }); + + is($failures, 0, 'a non-verbose Not Checked entry does not count as a failure'); + like($output, qr/\QNew $ip (PUBLIC)\E/, 'addtitle announces the new public IP that has no cached result'); + like($output, qr/Not Checked/, 'placeholder confirms the IP has not been actively checked'); + } +}; + +subtest 'report() silently skips non-PUBLIC IPs when verbose is off' => sub { + my ($failures, $output) = run_report_with_ips({ '10.0.0.1' => 1 }); + + is($failures, 0, 'a skipped private IP does not raise the failure count'); + unlike($output, qr/10\.0\.0\.1/, 'a non-PUBLIC IP produces no titled output in non-verbose mode'); + unlike($output, qr/Not Checked/, 'no Not Checked placeholder is emitted for skipped IPs'); +}; + +done_testing; From 27e774bd1671501608d078deec9e1d59e7681231 Mon Sep 17 00:00:00 2001 From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com> Date: Mon, 25 May 2026 09:46:14 +0200 Subject: [PATCH 5/8] test(servercheck): add unit coverage for ConfigServer::ServerCheck The module's surface is dominated by hardcoded reads of /etc/csf, /proc, /usr/local/cpanel, and shellouts to iptables/systemctl/chkconfig, all captured into a file-scope output lexical. Without Phase-B refactor seams this can only be smoke-tested. Asserts that the module loads cleanly, exports the documented entry points, and that getportinfo() returns 0 for out-of-range ports when /proc/net is available (skipped elsewhere). --- .github/tests/unit/servercheck.t | 70 ++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/tests/unit/servercheck.t diff --git a/.github/tests/unit/servercheck.t b/.github/tests/unit/servercheck.t new file mode 100644 index 0000000..c3a3e93 --- /dev/null +++ b/.github/tests/unit/servercheck.t @@ -0,0 +1,70 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use FindBin qw($Bin); +use Test::More; +use lib "$Bin/../lib"; + +use TestBootstrap (); + +# ServerCheck pulls in ConfigServer::Service, which caches loadconfig() at +# import time and rejects an empty IPTABLES path. Prime the module chain with +# a benign mocked config before any other test code touches it. +# +# NOTE: ServerCheck.pm intentionally has a small unit-test surface here. +# Almost every public sub (report, firewallcheck, servicescheck, mailcheck, +# apachecheck, phpcheck, whmcheck, sshtelnetcheck, dacheck) opens hardcoded +# paths under /etc/csf, /proc, /usr/local/cpanel etc., shells out to iptables/ +# systemctl/chkconfig, and writes results to a file-scope $output lexical. +# Without refactor-time seams these cannot be driven from a unit test on this +# machine. Phase B should extract the parsing helpers (firewall config line +# parser, services list filter, /proc/net port scan) into pure modules. +BEGIN { + TestBootstrap::reload_module_with_config( + 'ConfigServer::ServerCheck', + { IPTABLES => '/bin/true', HOST => '/bin/true', IPV6 => 0, DEBUG => 0 }, + also_delete => [qw(ConfigServer::Service ConfigServer::GetIPs)], + ); +} + +subtest 'module loads cleanly and exposes the documented check entry points' => sub { + can_ok( + 'ConfigServer::ServerCheck', + qw( + report startoutput endoutput addline addtitle + firewallcheck servercheck mailcheck apachecheck phpcheck + whmcheck dacheck sshtelnetcheck servicescheck getportinfo + ), + ); +}; + +subtest 'ipv4reg and ipv6reg class methods return non-empty regex strings' => sub { + require ConfigServer::Config; + + my $v4 = ConfigServer::Config->ipv4reg; + my $v6 = ConfigServer::Config->ipv6reg; + + ok(defined $v4 && length $v4, 'ipv4reg returns a non-empty regex string'); + ok(defined $v6 && length $v6, 'ipv6reg returns a non-empty regex string'); + like('192.0.2.10', qr/^$v4$/, 'ipv4reg matches a dotted-quad IPv4 address'); + like('2001:db8::1', qr/^$v6$/, 'ipv6reg matches a compact IPv6 address'); +}; + +subtest 'getportinfo returns 0 for ports that cannot occur in /proc/net' => sub { + SKIP: { + skip 'Linux /proc/net is required for getportinfo', 2 + unless -r '/proc/net/tcp'; + + # Port numbers above 65535 cannot be present in any /proc/net file, so + # getportinfo must return 0 regardless of what is actually bound. + my $hit = ConfigServer::ServerCheck::getportinfo(99999); + is($hit, 0, 'out-of-range port number is reported as not listening'); + + my $hit_neg = ConfigServer::ServerCheck::getportinfo(-1); + is($hit_neg, 0, 'negative port number is reported as not listening'); + } +}; + +done_testing; From 15d23bdeb67f0b42b35d95ad4666f5c802e1aec1 Mon Sep 17 00:00:00 2001 From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com> Date: Mon, 25 May 2026 10:32:59 +0200 Subject: [PATCH 6/8] test(cloudflare): stub LWP::UserAgent so the suite runs without libwww-perl CloudFlare.pm has a top-level 'use LWP::UserAgent', which fails to compile on CI runners (or any host) without libwww-perl installed. Pre-populate $INC and provide minimal package stubs so the module loads in isolation; each subtest still overrides the relevant LWP::UserAgent method with a local *glob assignment, so behaviour is unchanged. --- .github/tests/unit/cloudflare.t | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/tests/unit/cloudflare.t b/.github/tests/unit/cloudflare.t index 4ab4dae..eb607ed 100644 --- a/.github/tests/unit/cloudflare.t +++ b/.github/tests/unit/cloudflare.t @@ -7,6 +7,17 @@ use FindBin qw($Bin); use Test::More; use lib "$Bin/../lib"; +BEGIN { + # Stub LWP::UserAgent so CloudFlare.pm can be loaded on hosts (incl. CI + # runners) without libwww-perl installed. Each subtest still injects its + # own behaviour via `local *LWP::UserAgent::method = sub { ... }`. + $INC{'LWP/UserAgent.pm'} = __FILE__; + no strict 'refs'; + *{'LWP::UserAgent::new'} = sub { my $c = shift; bless {}, $c }; + *{'LWP::UserAgent::post'} = sub { die 'LWP::UserAgent::post must be stubbed by the active subtest' }; + *{'LWP::UserAgent::delete'} = sub { die 'LWP::UserAgent::delete must be stubbed by the active subtest' }; +} + use TestBootstrap (); { From b5fac426a9169e8340d32131ae1b56b629f47ee4 Mon Sep 17 00:00:00 2001 From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com> Date: Mon, 25 May 2026 10:58:01 +0200 Subject: [PATCH 7/8] test(messenger): expand coverage for init() and _read_request_line Adds the higher-value messenger coverage that this fork was missing and that fits the existing test infrastructure without invasive changes to Messenger.pm: * module public surface: can('init'), can('start'), can('messengerv2'), $VERSION presence + numeric baseline check (catches accidental renames/regressions) * init(1) returns a blessed object once ConfigServer::GetEthDev is stubbed out (no real network probing) * init(3) returns a blessed object without dying when /var/lib/csf/ssl/ is not writable (CI runner case) * init(1) tolerates a populated RECAPTCHA_NAT list (spaces + multiple comma-separated entries) * init(1) tolerates MESSENGER6 + IPV6 enabled together * _read_request_line accepts a line of exactly MAX_LINE_LENGTH bytes terminated by LF (the upper-bound boundary case) Subtests that introspect %ConfigServer::Messenger::config (cached config matrices: DEBUG levels, MESSENGER_HTTPS_SKIPMAIL, RECAPTCHA_SITEKEY, webserver type) are intentionally not added here: %config is a file- lexical 'my' in this fork's Messenger.pm, so it cannot be read from a test without an out-of-scope re-scoping change. Those checks effectively exercise ConfigServer::Config, not Messenger, and belong in a separate ConfigServer::Config test. messenger.t: 4 subtests -> 10 subtests, 7 asserts -> 17 asserts. --- .github/tests/unit/messenger.t | 81 ++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/.github/tests/unit/messenger.t b/.github/tests/unit/messenger.t index 0cd0017..292551d 100644 --- a/.github/tests/unit/messenger.t +++ b/.github/tests/unit/messenger.t @@ -40,6 +40,27 @@ sub load_messenger { return; } +# Override ConfigServer::GetEthDev so init(1) can run without probing real +# network interfaces. Callers wrap a `local` block around their init() call. +sub _stub_getethdev { + no warnings qw(redefine once); + *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' }; + *ConfigServer::GetEthDev::ipv4 = sub { return () }; + *ConfigServer::GetEthDev::ipv6 = sub { return () }; + return; +} + +subtest 'module exposes the documented public surface and a numeric VERSION' => sub { + load_messenger(); + + ok(ConfigServer::Messenger->can('init'), 'init() is part of the public API'); + ok(ConfigServer::Messenger->can('start'), 'start() is part of the public API'); + ok(ConfigServer::Messenger->can('messengerv2'), 'messengerv2() is callable as a class/package method'); + ok(defined $ConfigServer::Messenger::VERSION, '$VERSION is defined at package level'); + cmp_ok($ConfigServer::Messenger::VERSION, '>=', 3.00, + '$VERSION is at or above the documented 3.x baseline'); +}; + subtest 'init returns a blessed instance for version 2 without touching ssl directories' => sub { load_messenger(); @@ -49,6 +70,53 @@ subtest 'init returns a blessed instance for version 2 without touching ssl dire can_ok($obj, 'start'); }; +subtest 'init(1) returns a blessed instance once GetEthDev is stubbed out' => sub { + load_messenger(); + + no warnings qw(redefine once); + local *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' }; + local *ConfigServer::GetEthDev::ipv4 = sub { return () }; + local *ConfigServer::GetEthDev::ipv6 = sub { return () }; + + my $obj = ConfigServer::Messenger->init(1); + + isa_ok($obj, 'ConfigServer::Messenger', 'init(1) returns a Messenger object for the v1 (HTTP/HTTPS) flavor'); +}; + +subtest 'init(3) returns a blessed instance without dying on mkdir failures' => sub { + load_messenger(); + + my $obj = eval { ConfigServer::Messenger->init(3) }; + is($@, '', 'init(3) does not throw when /var/lib/csf/ssl is not writable'); + isa_ok($obj, 'ConfigServer::Messenger', 'init(3) returns a Messenger object for the v3 (HTTPS-CT) flavor'); +}; + +subtest 'init(1) tolerates a populated RECAPTCHA_NAT list with spaces and multiple entries' => sub { + load_messenger(RECAPTCHA_NAT => '192.168.1.1, 10.0.0.1, 172.16.0.5'); + + no warnings qw(redefine once); + local *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' }; + local *ConfigServer::GetEthDev::ipv4 = sub { return () }; + local *ConfigServer::GetEthDev::ipv6 = sub { return () }; + + my $obj = eval { ConfigServer::Messenger->init(1) }; + is($@, '', 'init(1) does not die when RECAPTCHA_NAT carries a comma-separated IP list with whitespace'); + isa_ok($obj, 'ConfigServer::Messenger', 'init(1) still returns a Messenger object with RECAPTCHA_NAT populated'); +}; + +subtest 'init(1) tolerates the MESSENGER6 + IPV6 combination' => sub { + load_messenger(MESSENGER6 => 1, IPV6 => 1); + + no warnings qw(redefine once); + local *ConfigServer::GetEthDev::new = sub { return bless {}, 'ConfigServer::GetEthDev' }; + local *ConfigServer::GetEthDev::ipv4 = sub { return () }; + local *ConfigServer::GetEthDev::ipv6 = sub { return () }; + + my $obj = eval { ConfigServer::Messenger->init(1) }; + is($@, '', 'init(1) does not die when MESSENGER6 and IPV6 are both enabled'); + isa_ok($obj, 'ConfigServer::Messenger', 'init(1) returns a Messenger object on an IPv6-enabled config'); +}; + subtest '_read_request_line returns an LF-terminated request line without the trailing newline' => sub { load_messenger(); @@ -81,4 +149,17 @@ subtest '_read_request_line stops reading once MAX_LINE_LENGTH bytes have been c unlike($line, qr/\n/, 'returned line never contains an embedded newline'); }; +subtest '_read_request_line accepts a line of exactly MAX_LINE_LENGTH bytes terminated by LF' => sub { + load_messenger(); + + my $max = ConfigServer::Messenger::MAX_LINE_LENGTH(); + my $payload = ('C' x $max) . "\n"; + my $client = Local::FakeMessengerClient->new($payload); + + my $line = ConfigServer::Messenger::_read_request_line($client); + + is(length($line), $max, 'a line exactly at MAX_LINE_LENGTH is read in full and the LF chomped'); + is($line, 'C' x $max, 'the returned line is exactly the bytes that were provided'); +}; + done_testing; From 659808a954f00c440582799737ffbdb1e21c042a Mon Sep 17 00:00:00 2001 From: zeroth-blip <262212448+zeroth-blip@users.noreply.github.com> Date: Mon, 25 May 2026 11:06:11 +0200 Subject: [PATCH 8/8] test(servercheck): rephrase NOTE to avoid naming specific control-panel installs Keeps the NOTE generic about hardcoded install-prefix paths without referencing a specific vendor by name. --- .github/tests/unit/servercheck.t | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/tests/unit/servercheck.t b/.github/tests/unit/servercheck.t index c3a3e93..f97fb6b 100644 --- a/.github/tests/unit/servercheck.t +++ b/.github/tests/unit/servercheck.t @@ -16,11 +16,12 @@ use TestBootstrap (); # NOTE: ServerCheck.pm intentionally has a small unit-test surface here. # Almost every public sub (report, firewallcheck, servicescheck, mailcheck, # apachecheck, phpcheck, whmcheck, sshtelnetcheck, dacheck) opens hardcoded -# paths under /etc/csf, /proc, /usr/local/cpanel etc., shells out to iptables/ -# systemctl/chkconfig, and writes results to a file-scope $output lexical. -# Without refactor-time seams these cannot be driven from a unit test on this -# machine. Phase B should extract the parsing helpers (firewall config line -# parser, services list filter, /proc/net port scan) into pure modules. +# paths under /etc/csf, /proc, and various control-panel install prefixes, +# shells out to iptables/systemctl/chkconfig, and writes results to a +# file-scope $output lexical. Without refactor-time seams these cannot be +# driven from a unit test on this machine. Phase B should extract the +# parsing helpers (firewall config line parser, services list filter, +# /proc/net port scan) into pure modules. BEGIN { TestBootstrap::reload_module_with_config( 'ConfigServer::ServerCheck', |