From 3b733505415d46cd37dd5a5337a903460dafb0d9 Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Thu, 9 Jan 2025 19:14:26 -0500 Subject: [PATCH 1/7] tests: dnsexit2 Convert tests to table-driven This makes the tests easier to read and extend. --- t/protocol_dnsexit2.pl | 282 ++++++++++++++++++++++------------------- 1 file changed, 151 insertions(+), 131 deletions(-) diff --git a/t/protocol_dnsexit2.pl b/t/protocol_dnsexit2.pl index 7b78fac..ac20cac 100644 --- a/t/protocol_dnsexit2.pl +++ b/t/protocol_dnsexit2.pl @@ -6,6 +6,9 @@ use ddclient::t::HTTPD; httpd_required(); +local $ddclient::globals{debug} = 1; +local $ddclient::globals{verbose} = 1; + ddclient::load_json_support('dnsexit2'); httpd()->run(sub { @@ -17,143 +20,160 @@ httpd()->run(sub { })]]; }); -local $ddclient::globals{verbose} = 1; - -sub decode_and_sort_array { - my ($data) = @_; - if (!ref $data) { - $data = decode_json($data); - } - @{$data->{update}} = sort { $a->{type} cmp $b->{type} } @{$data->{update}}; - return $data; +sub cmp_update { + my ($a, $b) = @_; + return $a->{name} cmp $b->{name} || $a->{type} cmp $b->{type}; } -subtest 'Testing nic_dnsexit2_update' => sub { - httpd()->reset(); - local %ddclient::config = ( - 'host.my.example.com' => { - 'usev4' => 'ipv4', - 'wantipv4' => '192.0.2.1', - 'usev6' => 'ipv6', - 'wantipv6' => '2001:db8::1', - 'protocol' => 'dnsexit2', - 'password' => 'mytestingpassword', - 'zone' => 'my.example.com', - 'server' => httpd()->endpoint(), - 'path' => '/update', - 'ttl' => 5 - }); - ddclient::nic_dnsexit2_update(undef, 'host.my.example.com'); - my @requests = httpd()->reset(); - is(scalar(@requests), 1, 'expected number of update requests'); - my $req = shift(@requests); - is($req->method(), 'POST', 'Method is correct'); - is($req->uri()->as_string(), '/update', 'URI contains correct path'); - is($req->header('content-type'), 'application/json', 'Content-Type header is correct'); - is($req->header('accept'), 'application/json', 'Accept header is correct'); - my $got = decode_and_sort_array($req->content()); - my $want = decode_and_sort_array({ - 'domain' => 'my.example.com', - 'apikey' => 'mytestingpassword', - 'update' => [ +sub sort_updates { + my ($req) = @_; + return { + %$req, + update => [sort({ cmp_update($a, $b); } @{$req->{update}})], + }; +} + +sub sort_reqs { + my @reqs = map(sort_updates($_), @_); + my @sorted = sort({ + my $ret = $a->{domain} cmp $b->{domain}; + $ret = @{$a->{update}} <=> @{$b->{update}} if !$ret; + my $i = 0; + while (!$ret && $i < @{$a->{update}} && $i < @{$b->{update}}) { + $ret = cmp_update($a->{update}[$i], $b->{update}[$i]); + } + return $ret; + } @reqs); + return @sorted; +} + +my @test_cases = ( + { + desc => 'both IPv4 and IPv6 are updated together', + cfg => { + 'host.my.example.com' => { + ttl => 5, + wantipv4 => '192.0.2.1', + wantipv6 => '2001:db8::1', + zone => 'my.example.com', + }, + }, + want => [{ + apikey => 'key', + domain => 'my.example.com', + update => [ + { + content => '192.0.2.1', + name => 'host', + ttl => 5, + type => 'A', + }, + { + content => '2001:db8::1', + name => 'host', + ttl => 5, + type => 'AAAA', + }, + ], + }], + }, + { + desc => 'zone defaults to host', + cfg => { + 'host.my.example.com' => { + ttl => 10, + wantipv4 => '192.0.2.1', + }, + }, + want => [{ + apikey => 'key', + domain => 'host.my.example.com', + update => [ + { + content => '192.0.2.1', + name => '', + ttl => 10, + type => 'A', + }, + ], + }], + }, + { + desc => 'two hosts, different zones', + cfg => { + 'host1.example.com' => { + ttl => 5, + wantipv4 => '192.0.2.1', + # 'zone' intentionally not set, so it will default to 'host1.example.com'. + }, + 'host2.example.com' => { + ttl => 10, + wantipv6 => '2001:db8::1', + zone => 'example.com', + }, + }, + want => [ { - 'type' => 'A', - 'name' => 'host', - 'content' => '192.0.2.1', - 'ttl' => 5, + apikey => 'key', + domain => 'host1.example.com', + update => [ + { + content => '192.0.2.1', + name => '', + ttl => 5, + type => 'A', + }, + ], }, { - 'type' => 'AAAA', - 'name' => 'host', - 'content' => '2001:db8::1', - 'ttl' => 5, - } - ] - }); - is_deeply($got, $want, 'Data is correct'); -}; + apikey => 'key', + domain => 'example.com', + update => [ + { + content => '2001:db8::1', + name => 'host2', + ttl => 10, + type => 'AAAA', + }, + ], + }, + ], + }, +); -subtest 'Testing nic_dnsexit2_update without a zone set' => sub { - httpd()->reset(); - local %ddclient::config = ( - 'myhost.example.com' => { - 'usev4' => 'ipv4', - 'wantipv4' => '192.0.2.1', - 'protocol' => 'dnsexit2', - 'password' => 'anotherpassword', - 'server' => httpd()->endpoint(), - 'path' => '/update-alt', - 'ttl' => 10 - }); - ddclient::nic_dnsexit2_update(undef, 'myhost.example.com'); - my @requests = httpd()->reset(); - is(scalar(@requests), 1, 'expected number of update requests'); - my $req = shift(@requests); - my $got = decode_and_sort_array($req->content()); - my $want = decode_and_sort_array({ - 'domain' => 'myhost.example.com', - 'apikey' => 'anotherpassword', - 'update' => [ - { - 'type' => 'A', - 'name' => '', - 'content' => '192.0.2.1', - 'ttl' => 10, - } - ] - }); - is_deeply($got, $want, 'Data is correct'); -}; - -subtest 'Testing nic_dnsexit2_update with two hostnames, one with a zone and one without' => sub { - httpd()->reset(); - local %ddclient::config = ( - 'host1.example.com' => { - 'usev4' => 'ipv4', - 'wantipv4' => '192.0.2.1', - 'protocol' => 'dnsexit2', - 'password' => 'testingpassword', - 'server' => httpd()->endpoint(), - 'path' => '/update', - 'ttl' => 5 - }, - 'host2.example.com' => { - 'usev6' => 'ipv6', - 'wantipv6' => '2001:db8::1', - 'protocol' => 'dnsexit2', - 'password' => 'testingpassword', - 'server' => httpd()->endpoint(), - 'path' => '/update', - 'ttl' => 10, - 'zone' => 'example.com' +for my $tc (@test_cases) { + subtest($tc->{desc} => sub { + local $ddclient::_l = ddclient::pushlogctx($tc->{desc}); + local %ddclient::config = (); + my @hosts = keys(%{$tc->{cfg}}); + for my $h (@hosts) { + $ddclient::config{$h} = { + password => 'key', + path => '/update', + server => httpd()->endpoint(), + %{$tc->{cfg}{$h}}, + }; } - ); - ddclient::nic_dnsexit2_update(undef, 'host1.example.com', 'host2.example.com'); - my @requests = httpd()->reset(); - my @got = map(decode_and_sort_array($_->content()), @requests); - my @want = ( - decode_and_sort_array({ - 'domain' => 'host1.example.com', - 'apikey' => 'testingpassword', - 'update' => [{ - 'type' => 'A', - 'name' => '', - 'content' => '192.0.2.1', - 'ttl' => 5, - }], - }), - decode_and_sort_array({ - 'domain' => 'example.com', - 'apikey' => 'testingpassword', - 'update' => [{ - 'type' => 'AAAA', - 'name' => 'host2', - 'content' => '2001:db8::1', - 'ttl' => 10, - }], - }), - ); - is_deeply(\@got, \@want, 'data is correct'); -}; + ddclient::nic_dnsexit2_update(undef, @hosts); + my @requests = httpd()->reset(); + my @got; + for (my $i = 0; $i < @requests; $i++) { + subtest("request $i" => sub { + my $req = $requests[$i]; + is($req->method(), 'POST', 'method is POST'); + is($req->uri()->as_string(), '/update', 'path is /update'); + is($req->header('content-type'), 'application/json', 'Content-Type is JSON'); + is($req->header('accept'), 'application/json', 'Accept is JSON'); + my $got = decode_json($req->content()); + is(ref($got), 'HASH', 'request content is a JSON object'); + is(ref($got->{update}), 'ARRAY', 'JSON object has array "update" property'); + push(@got, $got); + }); + } + @got = sort_reqs(@got); + my @want = sort_reqs(@{$tc->{want}}); + is_deeply(\@got, \@want, 'request objects match'); + }); +} done_testing(); From b31e5e2f911080e348d76f767d3003a8f8c26738 Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Thu, 9 Jan 2025 19:22:51 -0500 Subject: [PATCH 2/7] tests: dnsexit2: Add test for two hosts in the same zone --- t/protocol_dnsexit2.pl | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/t/protocol_dnsexit2.pl b/t/protocol_dnsexit2.pl index ac20cac..85ea6c9 100644 --- a/t/protocol_dnsexit2.pl +++ b/t/protocol_dnsexit2.pl @@ -139,6 +139,47 @@ my @test_cases = ( }, ], }, + { + desc => 'two hosts, same zone', + cfg => { + 'host1.example.com' => { + ttl => 5, + wantipv4 => '192.0.2.1', + zone => 'example.com', + }, + 'host2.example.com' => { + ttl => 10, + wantipv6 => '2001:db8::1', + zone => 'example.com', + }, + }, + want => [ + { + apikey => 'key', + domain => 'example.com', + update => [ + { + content => '192.0.2.1', + name => 'host1', + ttl => 5, + type => 'A', + }, + ], + }, + { + apikey => 'key', + domain => 'example.com', + update => [ + { + content => '2001:db8::1', + name => 'host2', + ttl => 10, + type => 'AAAA', + }, + ], + }, + ], + }, ); for my $tc (@test_cases) { From d18b1cdb278dc23ffabdb599b11b8e208c91c091 Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Fri, 10 Jan 2025 16:51:59 -0500 Subject: [PATCH 3/7] logging: Move filtering and reactions to Logger class This increases overhead when the verbose or debug option is disabled, but makes it possible for tests to intercept debug, info, and fatal messages. --- ddclient.in | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/ddclient.in b/ddclient.in index c91d100..fea83bb 100755 --- a/ddclient.in +++ b/ddclient.in @@ -2510,9 +2510,11 @@ sub ynu { # provided (it is ignored if the `msg` keyword is present). sub log { my $self = shift; - my %args = (@_ % 2 ? (msg => pop) : (), @_); + my %args = (label => '', @_ % 2 ? (msg => pop) : (), @_); $args{ctx} = [$args{ctx} // ()] if ref($args{ctx}) eq ''; - return $self->_log(\%args); + $self->_log(\%args); + $self->_failed() if $args{label} eq 'FAILED'; + $self->_abort() if $args{label} eq 'FATAL'; } sub _log { @@ -2521,10 +2523,11 @@ sub ynu { # the caller's arrayref (in case it is reused in a future call). $args->{ctx} = [@{$self->{ctx}}, @{$args->{ctx}}]; return $self->{parent}->_log($args) if defined($self->{parent}); + return if $args->{label} eq 'DEBUG' && !ddclient::opt('debug'); + return if $args->{label} eq 'INFO' && !ddclient::opt('verbose'); my $buffer = $args->{msg} // ''; chomp($buffer); if (!$args->{raw}) { - $args->{label} //= ''; my $prefix = $args->{label} ne '' ? sprintf("%-8s ", $args->{label} . ':') : ''; $prefix .= "[$_]" for @{$args->{ctx}}; $prefix .= '> ' if $prefix; @@ -2544,6 +2547,20 @@ sub ynu { } } } + + sub _failed { + my ($self) = @_; + return $self->{parent}->_failed() if defined($self->{parent}); + $ddclient::result = 'FAILED'; + $ddclient::result if 0; # Suppress spurious "used only once: possible typo" warning. + } + + sub _abort { + my ($self) = @_; + return $self->{parent}->_abort() if defined($self->{parent}); + ddclient::sendmail(); + exit(1); + } } # Intended use: @@ -2552,12 +2569,12 @@ sub pushlogctx { my ($ctx) = @_; return ddclient::Logger->new($ctx, $_l); } sub logmsg { $_l->log(@_); } sub _logmsg_fmt { $_[0] eq 'ctx' ? (shift, shift) : (), (@_ > 1) ? sprintf(shift, @_) : shift; } -sub info { logmsg(email => 1, label => 'INFO', _logmsg_fmt(@_)) if opt('verbose'); } -sub debug { logmsg( label => 'DEBUG', _logmsg_fmt(@_)) if opt('debug'); } +sub info { logmsg(email => 1, label => 'INFO', _logmsg_fmt(@_)); } +sub debug { logmsg( label => 'DEBUG', _logmsg_fmt(@_)); } sub warning { logmsg(email => 1, label => 'WARNING', _logmsg_fmt(@_)); } -sub fatal { logmsg(email => 1, label => 'FATAL', _logmsg_fmt(@_)); sendmail(); exit(1); } +sub fatal { logmsg(email => 1, label => 'FATAL', _logmsg_fmt(@_)); } sub success { logmsg(email => 1, label => 'SUCCESS', _logmsg_fmt(@_)); } -sub failed { logmsg(email => 1, label => 'FAILED', _logmsg_fmt(@_)); $result = 'FAILED'; } +sub failed { logmsg(email => 1, label => 'FAILED', _logmsg_fmt(@_)); } sub prettytime { return scalar(localtime(shift)); } From 009033d4765aecdc7629d8f478651da42a2ff5de Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Fri, 10 Jan 2025 17:02:40 -0500 Subject: [PATCH 4/7] tests: Factor out duplicate log capture code --- Makefile.am | 1 + t/lib/ddclient/t/Logger.pm | 19 +++++++++++++++++++ t/protocol_directnic.pl | 20 ++------------------ t/protocol_dyndns2.pl | 20 ++------------------ 4 files changed, 24 insertions(+), 36 deletions(-) create mode 100644 t/lib/ddclient/t/Logger.pm diff --git a/Makefile.am b/Makefile.am index 6a0f32a..fcb38a4 100644 --- a/Makefile.am +++ b/Makefile.am @@ -160,5 +160,6 @@ EXTRA_DIST += $(handwritten_tests) \ t/lib/ddclient/Test/Fake/HTTPD/other-ca-cert.pem \ t/lib/ddclient/t.pm \ t/lib/ddclient/t/HTTPD.pm \ + t/lib/ddclient/t/Logger.pm \ t/lib/ddclient/t/ip.pm \ t/lib/ok.pm diff --git a/t/lib/ddclient/t/Logger.pm b/t/lib/ddclient/t/Logger.pm new file mode 100644 index 0000000..f4a1f30 --- /dev/null +++ b/t/lib/ddclient/t/Logger.pm @@ -0,0 +1,19 @@ +package ddclient::t::Logger; +BEGIN { eval { require 'ddclient'; } or BAIL_OUT($@); } +use parent qw(-norequire ddclient::Logger); + +sub new { + my ($class, $parent) = @_; + my $self = $class->SUPER::new(undef, $parent); + $self->{logs} = []; + return $self; +} + +sub _log { + my ($self, $args) = @_; + push(@{$self->{logs}}, $args) + if ($args->{label} // '') =~ qr/^(?:WARNING|FATAL|SUCCESS|FAILED)$/; + return $self->SUPER::_log($args); +} + +1; diff --git a/t/protocol_directnic.pl b/t/protocol_directnic.pl index 92b245a..967e182 100644 --- a/t/protocol_directnic.pl +++ b/t/protocol_directnic.pl @@ -3,6 +3,7 @@ BEGIN { SKIP: { eval { require Test::Warnings; 1; } or skip($@, 1); } } BEGIN { eval { require JSON::PP; 1; } or plan(skip_all => $@); JSON::PP->import(); } BEGIN { eval { require 'ddclient'; } or BAIL_OUT($@); } use ddclient::t::HTTPD; +use ddclient::t::Logger; httpd_required(); @@ -29,23 +30,6 @@ httpd()->run(sub { return [400, $headers, ['unexpected request: ' . $req->uri()]] }); -{ - package Logger; - use parent qw(-norequire ddclient::Logger); - sub new { - my ($class, $parent) = @_; - my $self = $class->SUPER::new(undef, $parent); - $self->{logs} = []; - return $self; - } - sub _log { - my ($self, $args) = @_; - push(@{$self->{logs}}, $args) - if ($args->{label} // '') =~ qr/^(?:WARNING|FATAL|SUCCESS|FAILED)$/; - return $self->SUPER::_log($args); - } -} - my $hostname = httpd()->endpoint(); my @test_cases = ( { @@ -149,7 +133,7 @@ for my $tc (@test_cases) { diag('=============================================================================='); local $ddclient::globals{debug} = 1; local $ddclient::globals{verbose} = 1; - my $l = Logger->new($ddclient::_l); + my $l = ddclient::t::Logger->new($ddclient::_l); local %ddclient::config = %{$tc->{cfg}}; local %ddclient::recap; { diff --git a/t/protocol_dyndns2.pl b/t/protocol_dyndns2.pl index b3130e0..749eddc 100644 --- a/t/protocol_dyndns2.pl +++ b/t/protocol_dyndns2.pl @@ -3,6 +3,7 @@ BEGIN { SKIP: { eval { require Test::Warnings; 1; } or skip($@, 1); } } use MIME::Base64; BEGIN { eval { require 'ddclient'; } or BAIL_OUT($@); } use ddclient::t::HTTPD; +use ddclient::t::Logger; httpd_required(); @@ -18,23 +19,6 @@ httpd()->run(sub { return undef; }); -{ - package Logger; - use parent qw(-norequire ddclient::Logger); - sub new { - my ($class, $parent) = @_; - my $self = $class->SUPER::new(undef, $parent); - $self->{logs} = []; - return $self; - } - sub _log { - my ($self, $args) = @_; - push(@{$self->{logs}}, $args) - if ($args->{label} // '') =~ qr/^(?:WARNING|FATAL|SUCCESS|FAILED)$/; - return $self->SUPER::_log($args); - } -} - my @test_cases = ( { desc => 'IPv4, single host, good', @@ -246,7 +230,7 @@ for my $tc (@test_cases) { diag('=============================================================================='); local $ddclient::globals{debug} = 1; local $ddclient::globals{verbose} = 1; - my $l = Logger->new($ddclient::_l); + my $l = ddclient::t::Logger->new($ddclient::_l); local %ddclient::config; local %ddclient::recap; $ddclient::config{$_} = { From 115f23dead3109d63686daaca7c2952f5432c583 Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Fri, 10 Jan 2025 17:04:38 -0500 Subject: [PATCH 5/7] tests: Option to select which log messages to capture --- t/lib/ddclient/t/Logger.pm | 26 +++++++++++++++++++++++--- t/protocol_directnic.pl | 2 +- t/protocol_dyndns2.pl | 2 +- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/t/lib/ddclient/t/Logger.pm b/t/lib/ddclient/t/Logger.pm index f4a1f30..5d41949 100644 --- a/t/lib/ddclient/t/Logger.pm +++ b/t/lib/ddclient/t/Logger.pm @@ -2,18 +2,38 @@ package ddclient::t::Logger; BEGIN { eval { require 'ddclient'; } or BAIL_OUT($@); } use parent qw(-norequire ddclient::Logger); +{ + package ddclient::t::LoggerAbort; + use overload '""' => qw(stringify); + sub new { + my ($class, %args) = @_; + return bless(\%args, $class); + } + sub stringify { + return 'logged a FATAL message'; + } +} + sub new { - my ($class, $parent) = @_; + my ($class, $parent, $labelre) = @_; my $self = $class->SUPER::new(undef, $parent); $self->{logs} = []; + $self->{_labelre} = $labelre; return $self; } sub _log { my ($self, $args) = @_; - push(@{$self->{logs}}, $args) - if ($args->{label} // '') =~ qr/^(?:WARNING|FATAL|SUCCESS|FAILED)$/; + my $lre = $self->{_labelre}; + my $lbl = $args->{label}; + push(@{$self->{logs}}, $args) if !defined($lre) || (defined($lbl) && $lbl =~ $lre); return $self->SUPER::_log($args); } +sub _abort { + my ($self) = @_; + push(@{$self->{logs}}, 'aborted'); + die(ddclient::t::LoggerAbort->new()); +} + 1; diff --git a/t/protocol_directnic.pl b/t/protocol_directnic.pl index 967e182..bc96152 100644 --- a/t/protocol_directnic.pl +++ b/t/protocol_directnic.pl @@ -133,7 +133,7 @@ for my $tc (@test_cases) { diag('=============================================================================='); local $ddclient::globals{debug} = 1; local $ddclient::globals{verbose} = 1; - my $l = ddclient::t::Logger->new($ddclient::_l); + my $l = ddclient::t::Logger->new($ddclient::_l, qr/^(?:WARNING|FATAL|SUCCESS|FAILED)$/); local %ddclient::config = %{$tc->{cfg}}; local %ddclient::recap; { diff --git a/t/protocol_dyndns2.pl b/t/protocol_dyndns2.pl index 749eddc..a5091cb 100644 --- a/t/protocol_dyndns2.pl +++ b/t/protocol_dyndns2.pl @@ -230,7 +230,7 @@ for my $tc (@test_cases) { diag('=============================================================================='); local $ddclient::globals{debug} = 1; local $ddclient::globals{verbose} = 1; - my $l = ddclient::t::Logger->new($ddclient::_l); + my $l = ddclient::t::Logger->new($ddclient::_l, qr/^(?:WARNING|FATAL|SUCCESS|FAILED)$/); local %ddclient::config; local %ddclient::recap; $ddclient::config{$_} = { From 3b10e376075a8155c17f0589e4413f8783db7b3d Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Fri, 10 Jan 2025 19:19:34 -0500 Subject: [PATCH 6/7] tests: dnsexit2: Add test for host outside of zone --- t/protocol_dnsexit2.pl | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/t/protocol_dnsexit2.pl b/t/protocol_dnsexit2.pl index 85ea6c9..57aa24e 100644 --- a/t/protocol_dnsexit2.pl +++ b/t/protocol_dnsexit2.pl @@ -3,6 +3,7 @@ BEGIN { SKIP: { eval { require Test::Warnings; 1; } or skip($@, 1); } } BEGIN { eval { require JSON::PP; 1; } or plan(skip_all => $@); JSON::PP->import(); } BEGIN { eval { require 'ddclient'; } or BAIL_OUT($@); } use ddclient::t::HTTPD; +use ddclient::t::Logger; httpd_required(); @@ -180,6 +181,16 @@ my @test_cases = ( }, ], }, + { + desc => 'host outside of zone', + cfg => { + 'host.example' => { + wantipv4 => '192.0.2.1', + zone => 'example.com', + }, + }, + want_fatal => qr{hostname does not end with the zone: example.com}, + }, ); for my $tc (@test_cases) { @@ -195,7 +206,13 @@ for my $tc (@test_cases) { %{$tc->{cfg}{$h}}, }; } - ddclient::nic_dnsexit2_update(undef, @hosts); + my $l = ddclient::t::Logger->new($ddclient::_l, qr/^FATAL$/); + my $err = do { + local $ddclient::_l = $l; + local $@; + (eval { ddclient::nic_dnsexit2_update(undef, @hosts); 1; }) + ? undef : ($@ // 'unknown error'); + }; my @requests = httpd()->reset(); my @got; for (my $i = 0; $i < @requests; $i++) { @@ -212,8 +229,19 @@ for my $tc (@test_cases) { }); } @got = sort_reqs(@got); - my @want = sort_reqs(@{$tc->{want}}); + my @want = sort_reqs(@{$tc->{want} // []}); is_deeply(\@got, \@want, 'request objects match'); + subtest('expected (or lack of) error' => sub { + if (is(defined($err), defined($tc->{want_fatal}), 'error existence') && defined($err)) { + my @got = @{$l->{logs}}; + if (is(scalar(@got), 2, 'logged two events')) { + is($got[0]->{label}, 'FATAL', 'first logged event is a FATAL message'); + like($got[0]->{msg}, $tc->{want_fatal}, 'first logged event message matches'); + is($got[1], 'aborted', 'second logged event is an "aborted" event'); + isa_ok($err, qw(ddclient::t::LoggerAbort)); + } + } + }); }); } From 63bf3512a4e8f01d3f4fd2bf9aa778745facb7d0 Mon Sep 17 00:00:00 2001 From: Richard Hansen Date: Wed, 29 May 2024 16:12:30 -0400 Subject: [PATCH 7/7] dnsexit2: Update multiple hosts at a time when possible --- ChangeLog.md | 5 +++ ddclient.in | 84 ++++++++++++++++++++++-------------------- t/protocol_dnsexit2.pl | 6 --- 3 files changed, 49 insertions(+), 46 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 579f686..a856fd9 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -16,6 +16,11 @@ repository history](https://github.com/ddclient/ddclient/commits/main). special characters are preserved literally. [#766](https://github.com/ddclient/ddclient/pull/766) +### New features + + * `dnsexit2`: Multiple hosts are updated in a single API call when possible. + [#684](https://github.com/ddclient/ddclient/pull/684) + ## 2025-01-07 v4.0.0-rc.2 ### Breaking changes diff --git a/ddclient.in b/ddclient.in index fea83bb..d296b5e 100755 --- a/ddclient.in +++ b/ddclient.in @@ -4097,54 +4097,55 @@ EoEXAMPLE ###################################################################### sub nic_dnsexit2_update { my $self = shift; - # The DNSExit API does not support updating hosts with different zones at the same time, - # handling update per host. for my $h (@_) { $config{$h}{'zone'} = $h if !defined(opt('zone', $h)); - dnsexit2_update_host($h); } + dnsexit2_update_hostgroup($_) for group_hosts_by(\@_, qw(password path server ssl zone)); } -sub dnsexit2_update_host { - my ($h) = @_; - local $_l = pushlogctx($h); - my $name = $h; - # Remove the zone suffix from $name. If the zone eq $name, $name can be left alone or - # set to the empty string; both have identical semantics. For consistency, always - # remove the zone even if it means $name becomes the empty string. - my $zone = opt('zone', $h); - if ($name =~ s/(?:^|\.)\Q$zone\E$//) { - # The zone was successfully trimmed from $name. - } else { - fatal("hostname does not end with the zone: " . opt('zone', $h)); - } - # The IPv4 and IPv6 addresses must be updated together in a single API call. - my %ips; +sub dnsexit2_update_hostgroup { + my ($group) = @_; + return unless @{$group->{hosts}} > 0; + local $_l = pushlogctx(join(', ', @{$group->{hosts}})); + my %hostips; my @updates; - for my $ipv ('4', '6') { - my $ip = delete($config{$h}{"wantipv$ipv"}) or next; - $ips{$ipv} = $ip; - info("updating IPv$ipv address to $ip"); - $recap{$h}{"status-ipv$ipv"} = 'failed'; - push(@updates, { - name => $name, - type => ($ipv eq '6') ? 'AAAA' : 'A', - content => $ip, - ttl => opt('ttl', $h), - }); - }; - my $url = opt('server', $h) . opt('path', $h); + for my $h (@{$group->{hosts}}) { + local $_l = pushlogctx($h) if @{$group->{hosts}} > 1; + my $name = $h; + # Remove the zone suffix from $name. If the zone eq $name, $name can be left alone or + # set to the empty string; both have identical semantics. For consistency, always + # remove the zone even if it means $name becomes the empty string. + if ($name =~ s/(?:^|\.)\Q$group->{cfg}{'zone'}\E$//) { + # The zone was successfully trimmed from $name. + } else { + fatal("hostname does not end with the zone: $group->{cfg}{'zone'}"); + } + # The IPv4 and IPv6 addresses must be updated together in a single API call. + for my $ipv ('4', '6') { + my $ip = delete($config{$h}{"wantipv$ipv"}) or next; + $hostips{$h}{$ipv} = $ip; + info("updating IPv$ipv address to $ip"); + $recap{$h}{"status-ipv$ipv"} = 'failed'; + push(@updates, { + name => $name, + type => ($ipv eq '6') ? 'AAAA' : 'A', + content => $ip, + ttl => opt('ttl', $h), + }); + } + } + return unless @updates > 0; my $reply = geturl( proxy => opt('proxy'), - url => $url, + url => $group->{cfg}{'server'} . $group->{cfg}{'path'}, headers => [ 'Content-Type: application/json', 'Accept: application/json', ], method => 'POST', data => encode_json({ - apikey => opt('password', $h), - domain => $zone, + apikey => $group->{cfg}{'password'}, + domain => $group->{cfg}{'zone'}, update => \@updates, }), ); @@ -4191,12 +4192,15 @@ sub dnsexit2_update_host { return; } success($message); - $recap{$h}{'mtime'} = $now; - keys(%ips); # Reset internal iterator. - while (my ($ipv, $ip) = each(%ips)) { - $recap{$h}{"ipv$ipv"} = $ip; - $recap{$h}{"status-ipv$ipv"} = 'good'; - success("updated IPv$ipv address to $ip"); + keys(%hostips); # Reset internal iterator. + while (my ($h, $ips) = each(%hostips)) { + $recap{$h}{'mtime'} = $now; + keys(%$ips); # Reset internal iterator. + while (my ($ipv, $ip) = each(%$ips)) { + $recap{$h}{"ipv$ipv"} = $ip; + $recap{$h}{"status-ipv$ipv"} = 'good'; + success("updated IPv$ipv address to $ip"); + } } } diff --git a/t/protocol_dnsexit2.pl b/t/protocol_dnsexit2.pl index 57aa24e..9991e7c 100644 --- a/t/protocol_dnsexit2.pl +++ b/t/protocol_dnsexit2.pl @@ -165,12 +165,6 @@ my @test_cases = ( ttl => 5, type => 'A', }, - ], - }, - { - apikey => 'key', - domain => 'example.com', - update => [ { content => '2001:db8::1', name => 'host2',