Files
Malin 8d4eaa1489 feat: add full Zonemaster stack with Docker and Spanish UI
- Clone all 5 Zonemaster component repos (LDNS, Engine, CLI, Backend, GUI)
- Dockerfile.backend: 8-stage multi-stage build LDNS→Engine→CLI→Backend
- Dockerfile.gui: Astro static build served via nginx
- docker-compose.yml: backend (internal) + frontend (port 5353)
- nginx.conf: root redirects to /es/, /api/ proxied to backend
- zonemaster-gui/config.ts: defaultLanguage set to 'es' (Spanish)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 08:19:24 +02:00

700 lines
20 KiB
Perl
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package TestUtil::DSL;
use warnings;
use strict;
use v5.24;
use Carp qw(confess croak);
use TestUtil::DSL::Compiler;
# This is needed in order to squelch “too late to run INIT block” warnings.
use Zonemaster::Engine ();
=encoding utf8
=head1 NAME
TestUtil::DSL - a domain-specific language for easy testing of test cases
=head1 SYNOPSIS
Because this package lies in the testing directory C<t/>, which is not listed in C<@INC> during testing,
it must be loaded using the following code:
use File::Basename qw( dirname );
use File::Spec::Functions qw( rel2abs );
use lib dirname( rel2abs( $0 ) );
use TestUtil::DSL;
A test case can then be tested with the following minimal skeleton:
testing_test_case 'Example', 'example01';
all_tags qw(EX01_ALL_GOOD
EX01_MINOR_ISSUE
EX01_SOMETHING_WRONG
EX01_SOME_OTHER_PROBLEM
EX01_BREAKS_ON_SOME_FEATURE);
root_hints 'ns1' => [ qw(127.1.0.1 fda1:b2:c3::127:1:0:1) ],
'ns2' => [ qw(127.1.0.2 fda1:b2:c3::127:1:0:2) ];
zone_name_template '{SCENARIO}.{TESTCASE}.xa';
scenario 'GOOD-{1..3}' => sub {
expect EX01_ALL_GOOD;
};
# more scenarios here
no_more_scenarios;
=cut
use Exporter 'import';
our @EXPORT = qw(all_tags
clear_cache
expect
expect_others
fake_ds
fake_ns
forbid
forbid_others
no_more_scenarios
not_testable
root_hints
scenario
testing_test_case
todo
zone
zone_name_template);
# Define a stack for internal context objects, some functions to manipulate it,
# and only expose the top of the stack.
{
my @STACK = ();
sub STATE () {
$STACK[-1];
}
sub check_state ($) {
my ($expected_state) = @_;
if (defined $expected_state) {
if (scalar @STACK == 0 or $expected_state ne STATE->{_STATE}) {
croak "This keyword is not valid in this context."
}
}
elsif (scalar @STACK != 0) {
croak "This keyword can only be used once";
}
}
sub pop_state {
my $result = pop @STACK // croak "Attempted to pop empty stack";
delete $result->{_STATE};
return $result;
}
sub push_state ($) {
push @STACK, { _STATE => $_[0] };
}
}
# Internal mechanics
sub _is_uint_bits {
my ($value, $bits) = @_;
return ($value =~ /^\d+$/a and $value >= 0 and $value < (1 << $bits));
}
sub _expand_scenario_names {
my @scenario_names = @_;
my @result;
for my $name (@scenario_names) {
if ($name=~ /^ (?<prefix> [^{]*) \{
(?<start> \d+) \Q..\E (?<end> \d+)
\} $ /x ) {
for my $i ($+{start}..$+{end}) {
push @result, "$+{prefix}$i";
}
}
else {
push @result, $name;
}
}
return @result;
}
=head1 TOP-LEVEL KEYWORDS
=head2 all_tags
all_tags qw(SOME_TAG SOME_OTHER_TAG AND_ANOTHER_ONE);
List all the message tags that the test case being tested is expected to emit.
=cut
sub all_tags (@) {
my @tags = @_;
check_state 'testing_test_case';
%{STATE->{all_tags}} = map { $_ => 1 } @tags;
}
=head2 no_more_scenarios
no_more_scenarios;
Ends a declaration of a test. Similar to C<done_testing> in L<Test::More>.
=cut
sub no_more_scenarios () {
check_state 'testing_test_case';
my $spec = pop_state;
# Do not clutter AST with data the compiler does not need
delete $spec->{zone_name_template};
delete $spec->{all_tags};
TestUtil::DSL::Compiler::compile($spec)->();
}
=head2 scenario
Declares one or more scenario blocks.
A scenario is defined by a name and a set of expectations. The name is used to
derive the name of a zone on which to run the test case being tested. The
result of this run is a collection of messages, which are then compared to the
expected result declared in the scenario block.
The scenario block body is a special coderef, evaluated in a special context which
gives meaning to a set of scenario-specific keywords. See L<SCENARIO-SPECIFIC
KEYWORDS> for more information on what keywords are valid in a scenario block.
The C<scenario> keywords has several legal syntaxes.
The first form declares a single scenario:
scenario 'SCENARIO-NAME' => sub {
# scenario declaration here
};
The second form declares multiple scenarios, all sharing the same expectations:
scenario qw(SCENARIO-ONE SCENARIO-TWO) => sub {
# scenario declaration here
};
Scenario names may end with a number range expressed as C<{M..N}> where C<M>
and C<N> are integers. This is a shorthand for listing scenarios that all
share the same prefix but differ only by a numbered suffix. This notation
allows for concise declarations of scenarios that may be configured
differently but are expected to yield the same set of messages from the test
case. For example, the following two declarations are equivalent:
scenario qw(GOOD-{1..2} EXAMPLE-{1..4}) => sub { ... };
scenario qw(GOOD-1 GOOD-2
EXAMPLE-1 EXAMPLE-2 EXAMPLE-3 EXAMPLE-4) => sub { ... };
=cut
sub scenario (@) {
check_state 'testing_test_case';
croak "At least two arguments required" unless scalar @_ >= 2;
croak "A 'root_hints' must appear before a 'scenario' block"
unless exists STATE->{root_hints};
my $definition = pop @_;
my @names = _expand_scenario_names(@_);
my @context = caller(0);
my $scenario = {
names => \@names,
caller => [ @context[1..2] ],
body => do {
my $PARENT_STATE = STATE;
push_state 'scenario';
STATE->{status} = [ 'testable' ];
# Keep track of contents of “all_tags” so that expect and forbid
# can raise errors if given a tag not in that list.
STATE->{all_tags} = $PARENT_STATE->{all_tags};
# Keep track of keys never mentioned by “expect” or “forbid” respectively
# so that “expect_others” and “forbid_others” can be defined in terms
# of “expect” and “forbid” respectively.
%{STATE->{not_expected}} = map { $_ => 1 } keys %{STATE->{all_tags}};
%{STATE->{not_forbidden}} = map { $_ => 1 } keys %{STATE->{all_tags}};
# Evaluate definition
my $obj = do { $definition->(); pop_state; };
# Clean up
delete $obj->{all_tags};
delete $obj->{not_expected};
delete $obj->{not_forbidden};
# Set default zone name (template)
$obj->{zone} //= STATE->{zone_name_template};
# Sanity checks
if (not defined $obj->{zone}) {
croak "No 'zone' keyword in scenario block and no previous "
. "'zone_name_template' seen";
}
# TODO do we need more sanity checks?
$obj;
}
};
foreach my $name (@{$scenario->{names}}) {
STATE->{scenario_status}{$name} = $scenario->{body}{status};
}
push @{STATE->{ops}}, [ scenario => $scenario ];
}
=head2 testing_test_case
testing_test_case 'MyModule' 'mymodule01';
Declare a unit test for a test case. The arguments for that keyword are the
name of the test plan, and the name of the test case being tested
respectively.
The DSL expects this keyword to be used first, before any other keyword.
=cut
sub testing_test_case ($$) {
check_state undef;
push_state 'testing_test_case';
my ($test_module, $test_case) = @_;
STATE->{test_module} = $test_module;
STATE->{test_case} = $test_case;
}
=head2 root_hints
root_hints 'ns1.example' => [ qw(198.51.113.10 2001:db8:0:8::53) ],
'ns2.example' => [ qw(198.51.113.20 2001:db8:0:8::1:53) ];
Declare the set of name servers which are authoritative for the root zone.
These root hints apply to all scenarios that follow this keyword.
This keyword must be used at least one before defining the first scenario
block.
=cut
sub root_hints (%) {
my (%root_hints) = @_;
check_state 'testing_test_case';
STATE->{root_hints} = 1;
push @{STATE->{ops}}, [ root_hints => \%root_hints ];
}
=head2 zone_name_template
zone_name_template '{SCENARIO}.{TESTCASE}.xa';
zone_name_template 'child.parent.{SCENARIO}.{TESTCASE}.xa';
Declare a default zone name template for the C<scenario> specifications that follow.
The string passed as arguments may contain placeholders, such as C<{SCENARIO}>
or C<{TESTCASE}>, that are substituted accordingly.
=cut
sub zone_name_template ($) {
my ($template) = @_;
check_state 'testing_test_case';
STATE->{zone_name_template} = $template;
}
=head2 clear_cache
clear_cache;
Clears the resolver cache for subsequent tests. This might be necessary if
this caching somehow causes negative side effects.
Note that it is unnecessary to use this keyword right after L<root_hints>,
because L<root_hints> also clears the resolver cache.
=cut
sub clear_cache () {
check_state 'testing_test_case';
push @{STATE->{ops}}, [ "clear_cache" ];
}
=head1 SCENARIO-SPECIFIC KEYWORDS
=head2 zone
zone 'myzone.example.xa';
zone 'myzone.{SCENARIO}.{TESTCASE}.xb';
Declare a zone name to be used in the scenario.
This declaration is optional, because the default value is inherited from the
top-level keyword L<zone_name_template>. It can be useful for one or two
exceptions to a general rule.
The same placeholders that are valid for L<zone_name_template> can also be
used here.
=cut
sub zone ($) {
my ($zone) = @_;
check_state 'scenario';
# Template expansion is done at a later stage
STATE->{zone} = $zone;
}
=head2 fake_ds
fake_ds <key tag>, <algo>, <type>, <digest>
fake_ds 51966, 8, 3, 'ABCDABCDABCDABCD';
Provide a DS record as part of the fake delegation to be used in the scenario.
=cut
sub fake_ds($$$$) {
my ($tag, $algo, $type, $digest) = @_;
unless ( _is_uint_bits($tag, 16) ) {
croak "$tag: not a valid key tag";
}
unless ( _is_uint_bits($algo, 8) ) {
croak "$algo: not a valid algorithm";
}
unless ( _is_uint_bits($type, 8) ) {
croak "$type: not a valid type";
}
unless ( $digest =~ /^ (?: [0-9a-f]{2} )+ $/ix ) {
croak "$digest: not a valid digest";
}
push @{STATE->{fake_ds}}, {
keytag => $tag,
algorithm => $algo,
type => $type,
digest => $digest
};
}
=head2 fake_ns
fake_ns <nameserver>[, <IP address…>]
Provide an NS record as part of the fake delegation to be used in the scenario.
The keyword accepts a name server name and optionally a list of IP addresses that
will be used as glue records if given.
Examples:
fake_ns 'ns1.example';
fake_ns 'ns1.example' => '192.0.2.50';
fake_ns 'ns1.example' => '192.0.2.50', '2001:db8:0:8::53';
Passing the same name server name to more than one C<fake_ns> keyword is not
allowed and is an error.
=cut
sub fake_ns($@) {
my ($name, @ips) = @_;
if (exists STATE->{fake_ns}{$name}) {
croak "'fake_ns' cannot be used for '$name' more than once";
}
STATE->{fake_ns}{$name} = \@ips;
}
=head2 expect
List one or more tags that the test case is expected to generate when it is run
on the scenario being defined.
This keyword allows for multiple syntaxes.
The first form specifies that at least one message of a certain tag is to be
expected among the test cases output for the scenario:
expect <tag>;
The second form is a shorthand that avoids repeating the C<expect> keyword. It
is equivalent to using the first form as many times as there are tags given as
arguments.
expect <tag1>, <tag2>, ...;
The third form specifies a single tag name and a hashref of keys and criteria.
It specifies that a message whose tag matches the name and whose arguments
exist and match the supplied criteria must exist among the test cases output.
See L<Criteria> below for a more in-depth explanation of this form.
expect <tag> => { <argument1> => <criterion1>, ... };
The fourth form takes a tag name and a coderef: it searches for all messages
whose tag matches, evaluates the coderef with C<@_> set to the list of
messages matching the tag and expects the coderef to return a true value. This
form is useful for situations where the third form falls short. See L<Criteria>
below for examples.
expect <tag> => sub { my @messages = @_; ... };
The C<expect> keyword can be used more than once in a scenario. All of the
checks specified by C<expect> and C<forbid> keywords must pass in order for
the scenario to pass.
=head3 Criteria
For the third form, arguments can be matched against:
=over
=item strings, which are compared with C<eq>;
=item regular expressions;
=item and coderefs, where C<$_> is bound to the message parameters value.
=back
The following expects C<SOME_TAG> to be generated with arguments C<ns_list>,
C<other_argument>, C<third_argument> and C<fourth_argument>. C<ns_list> must
be equal to C<ns1.example/127.0.60.1>, C<other_argument> must match
C</^ns2\..*/>, C<third_argument> must be case-insensitively equal to
C<example> and C<fourth_argument> is only tested for presence.
expect SOME_TAG => {
ns_list => 'ns1.example/127.0.60.1',
other_argument => qr/^ns2\..*/,
third_argument => sub { fc $_ eq fc 'EXAMPLE' },
fourth_argument => sub { 1 },
};
Note that if the test case emits C<SOME_TAG> with an argument that is not
listed in the C<expect> keyword, it is deemed an error.
Checking that a message tag has no parameters at all can also be done as
follows:
expect TAG_WITHOUT_PARAMETERS => {};
More free-form criteria can be provided by means of the fourth form. The following
example checks if the scenario generated exactly three times a given message tag:
expect SOME_TAG => sub { scalar @_ == 3 };
The following example ensures that all instances of SOME_TAG have a
C<some_argument> parameter equal to the string C<ns1.example/127.0.60.1>:
expect SOME_TAG => sub {
my @messages = @_;
for my $m (@messages) {
return 0 if $m->{some_argument} ne 'ns1.example/127.0.60.1';
}
return 1;
}
=cut
sub expect (@) {
if (scalar @_ == 2 and ref $_[1] eq 'HASH') {
# Third form
my ($tag, $args) = @_;
_declare_message_test( expect => $tag, args => $args );
}
elsif (scalar @_ == 2 and ref $_[1] eq 'CODE') {
# Fourth form
my ($tag, $code) = @_;
_declare_message_test( expect => $tag, code => $code );
}
elsif (scalar @_ >= 1) {
# First or second form
foreach my $tag (@_) {
_declare_message_test( expect => $tag );
}
}
else {
croak "Need at least one argument";
}
}
=head2 forbid
forbid <tag1>, <tag2>, …
Specifies that the listed tags are not to be emitted by the test case in the
scenario being defined. In other words, in the messages generated by the test
case, no message should appear whose tags are in the listed tags.
The C<forbid> keyword can be used more than once in a scenario. All of the
checks specified by C<expect> and C<forbid> keywords must pass in order for
the scenario to pass.
=cut
sub forbid (@) {
my @tags = @_;
croak "Need at least one argument" unless scalar @tags >= 1;
foreach my $tag (@_) {
_declare_message_test( forbid => $tag );
}
}
sub _declare_message_test {
my ( $type, $tag, %args ) = @_;
my @context = caller(1);
exists STATE->{all_tags}{$tag}
or croak "Tag '$tag' used in '$type' clause but not declared in 'all_tags'";
delete STATE->{not_expected}{$tag} if $type eq 'expect';
delete STATE->{not_forbidden}{$tag} if $type eq 'forbid';
push @{STATE->{message_tests}}, [ $type, [ @context[1..2] ], $tag, %args ];
}
=head2 expect_others
expect_others;
Specifies that all tags not listed by any C<forbid> tag in the scenario
definition must appear in the test cases output for the scenario.
=cut
sub expect_others () {
croak "Cannot use expect_others if forbid_others is already given" if STATE->{forbid_others};
croak "Cannot use expect_others more than once" if STATE->{expect_others};
STATE->{expect_others} = 1;
_declare_message_test( expect => $_ ) foreach ( keys %{STATE->{not_forbidden}} );
}
=head2 forbid_others
forbid_others;
Specifies that all tags not listed by any C<expect> tag in the scenario
definition must not appear in the test cases output for the scenario.
=cut
sub forbid_others () {
croak "Cannot use forbid_others if expect_others is already given" if STATE->{expect_others};
croak "Cannot use forbid_others more than once" if STATE->{forbid_others};
STATE->{forbid_others} = 1;
_declare_message_test( forbid => $_ ) foreach ( keys %{STATE->{not_expected}} );
}
=head2 not_testable
not_testable 'optional reason';
Mark the scenario as not testable.
Scenarios marked as not testable are skipped during testing, unless they are
listed explicitly in the C<ZONEMASTER_SELECTED_SUBTESTS> environment variable.
It is semantically equivalent to L<Test::More#todo_skip>.
This facility is useful for defining scenarios which cannot be tested because
the infrastructure (e.g. DNS servers, test zones…) needed to elicit the set of
messages expected by the scenario is not yet available.
No packets related to the scenario are saved to the tests C<.data> file when
C<ZONEMASTER_RECORD> is set in the environment. This means that no data is
recorded for a test zone that does not reflect the scenario accurately.
Rerecording is only necessary when the C<not_testable> keyword is removed.
The C<not_testable> keyword should not be used for other reasons than lack of
infrastructure, such as if the test fails because of a known bug in the test
cases implementation. In those situations, C<todo> is more appropriate.
The optional reason, if supplied, is used in the test harnesss diagnostic
output. It is useful for documenting the exact reason why the test is not
testable, for example by referring to an item on an issue tracker.
=cut
sub not_testable (;$) {
if (exists STATE->{todo}) {
croak "'not_testable' cannot be combined with 'todo'";
}
if (exists STATE->{not_testable}) {
croak "'not_testable' can only be given once";
}
STATE->{status} = [ 'not_testable', $_[0] ];
}
=head2 todo
todo 'optional reason';
Mark the scenario as “to do”.
Scenarios marked as to do are executed normally during testing, but are
expected to fail. Failing “to do” scenarios do not cause the parent test suite
to fail.
It is semantically equivalent to L<Test::More#TODO:-BLOCK>.
This facility is useful for defining scenarios that are expected to fail
because of a known bug in the test cases implementation that has not been
fixed yet.
Packets related to the scenario are saved to the tests C<.data> file when
C<ZONEMASTER_RECORD> is set in the environment. This means that no rerecord is
necessary when the C<todo> keyword is removed or when trying to fix the
implementation.
The optional reason, if supplied, is used in the test harnesss diagnostic
output. It is useful for documenting the exact reason why the test is expected
to fail, for example by referring to an item on an issue tracker.
=cut
sub todo (;$) {
if (exists STATE->{not_testable}) {
croak "'todo' cannot be combined with 'not_testable'";
}
if (exists STATE->{todo}) {
croak "'todo' can only be given once";
}
STATE->{status} = [ 'todo', $_[0] ];
}
1;