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{}, 'output opens with the standard table wrapper'); + like($html, qr{src='/img/lfd_hour\.gif\?text=\d+}, 'hour bar chart points at the configured image dir'); + like($html, qr{src='/img/lfd_pie_hour\.gif\?text=\d+}, 'hour pie chart is rendered'); + like($html, qr{src='/img/lfd_month\.gif\?text=\d+}, 'month chart is rendered'); + like($html, qr{src='/img/lfd_year\.gif\?text=\d+}, 'year chart is rendered'); + unlike($html, qr{lfd_cc\.gif}, 'country chart is omitted when cc_lookups is false'); +}; + +subtest 'charts_html adds the country chart when cc_lookups is enabled' => sub { + my $html = ConfigServer::ServerStats::charts_html(1, '/assets/'); + + like($html, qr{src='/assets/lfd_cc\.gif\?text=\d+}, 'country chart is rendered when cc_lookups is true'); + like($html, qr{src='/assets/lfd_year\.gif\?text=\d+}, 'year chart still rendered alongside cc chart'); +}; + +subtest 'minmaxavg tracks min, max, and running-sum AVG across calls' => sub { + reset_serverstats_state(); + + ConfigServer::ServerStats::minmaxavg('HOUR', '1cpu', 10); + ConfigServer::ServerStats::minmaxavg('HOUR', '1cpu', 30); + ConfigServer::ServerStats::minmaxavg('HOUR', '1cpu', 20); + + my $html = ConfigServer::ServerStats::graphs_html('/x/'); + + like($html, qr{cpu
Min:10\.00Max:30\.00Avg: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{cpuMin:10\.00Max:20\.00}, 'HOUR cpu bucket shows only the HOUR samples'); + like($html, qr{loadMin:5\.00Max:5\.00}, 'HOUR load bucket is independent of cpu'); + like($html, qr{cpuMin:100\.00Max:100\.00}, 'DAY cpu bucket is independent of HOUR cpu bucket'); + like($html, qr{diskMin:99\.00}, 'WEEK bucket shows the WEEK sample only'); + like($html, qr{memMin: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',