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, 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=~ /^ (? [^{]*) \{ (? \d+) \Q..\E (? \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 in L. =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 for more information on what keywords are valid in a scenario block. The C 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 and C 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 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, because L 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. It can be useful for one or two exceptions to a general rule. The same placeholders that are valid for L 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 , , , 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 [, ] 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 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 case’s output for the scenario: expect ; The second form is a shorthand that avoids repeating the C keyword. It is equivalent to using the first form as many times as there are tags given as arguments. expect , , ...; 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 case’s output. See L below for a more in-depth explanation of this form. expect => { => , ... }; 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 below for examples. expect => sub { my @messages = @_; ... }; The C keyword can be used more than once in a scenario. All of the checks specified by C and C 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; =item regular expressions; =item and coderefs, where C<$_> is bound to the message parameter’s value. =back The following expects C to be generated with arguments C, C, C and C. C must be equal to C, C must match C, C must be case-insensitively equal to C and C 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 with an argument that is not listed in the C 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 parameter equal to the string C: 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 , , … 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 keyword can be used more than once in a scenario. All of the checks specified by C and C 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 tag in the scenario definition must appear in the test case’s 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 tag in the scenario definition must not appear in the test case’s 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 environment variable. It is semantically equivalent to L. 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 test’s C<.data> file when C 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 keyword is removed. The C 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 case’s implementation. In those situations, C is more appropriate. The optional reason, if supplied, is used in the test harness’s 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. This facility is useful for defining scenarios that are expected to fail because of a known bug in the test case’s implementation that has not been fixed yet. Packets related to the scenario are saved to the test’s C<.data> file when C is set in the environment. This means that no rerecord is necessary when the C keyword is removed or when trying to fix the implementation. The optional reason, if supplied, is used in the test harness’s 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;