diff --git a/.github/tests/unit/cloudflare.t b/.github/tests/unit/cloudflare.t new file mode 100644 index 0000000..eb607ed --- /dev/null +++ b/.github/tests/unit/cloudflare.t @@ -0,0 +1,173 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +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 (); + +{ + 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; diff --git a/.github/tests/unit/messenger.t b/.github/tests/unit/messenger.t new file mode 100644 index 0000000..292551d --- /dev/null +++ b/.github/tests/unit/messenger.t @@ -0,0 +1,165 @@ +#!/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; +} + +# 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(); + + 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 '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(); + + 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'); +}; + +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; 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; diff --git a/.github/tests/unit/servercheck.t b/.github/tests/unit/servercheck.t new file mode 100644 index 0000000..f97fb6b --- /dev/null +++ b/.github/tests/unit/servercheck.t @@ -0,0 +1,71 @@ +#!/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, 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', + { 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; 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;