|
Line 0
Link Here
|
| 0 |
- |
1 |
package t::lib::Swagger2TestRunner; |
|
|
2 |
|
| 3 |
use Modern::Perl; |
| 4 |
use Test::More; |
| 5 |
|
| 6 |
use Swagger2; |
| 7 |
use YAML::XS; |
| 8 |
use String::Random; |
| 9 |
|
| 10 |
use C4::Context; |
| 11 |
|
| 12 |
use Koha::ApiKeys; |
| 13 |
use Koha::Auth::PermissionManager; |
| 14 |
use Koha::Auth::Permissions; |
| 15 |
use Koha::Auth::Challenge::RESTV1; |
| 16 |
|
| 17 |
use t::lib::WebDriverFactory; |
| 18 |
use t::lib::RESTTest; |
| 19 |
use t::lib::TestObjects::BorrowerFactory; |
| 20 |
use t::lib::TestObjects::ObjectFactory; |
| 21 |
|
| 22 |
use Koha::Exception::FeatureUnavailable; |
| 23 |
|
| 24 |
=head new |
| 25 |
|
| 26 |
my $swagger2TestRunner = t::lib::Swagger2TestRunner->new({ |
| 27 |
testKeywords => ['borrowers','^issues', '@/borrowers/{borrowernumber}'], |
| 28 |
}); |
| 29 |
|
| 30 |
Gets the testrunner for you to operate on the REST API tests. |
| 31 |
@PARAM1 HASHRef of parameters: { |
| 32 |
#Every route in the Swagger2 interface definition is matched against these |
| 33 |
#given keywords. Matching routes are targeted for operations. |
| 34 |
# Keywords starting with ^ are negated, so they must not match |
| 35 |
# Keywords starting with @ are complete matches, so they must match the url endpoint completely. A bit like a tag. |
| 36 |
# Keywords with no special starting character must be found from the endpoint/url. |
| 37 |
#The special starting character is removed before matching. |
| 38 |
testKeywords => ['borrowers','^issues', '@/borrowers/{borrowernumber}'], |
| 39 |
} |
| 40 |
=cut |
| 41 |
|
| 42 |
sub new { |
| 43 |
my ($class, $params) = @_; |
| 44 |
|
| 45 |
my $self = $params || {}; |
| 46 |
bless($self, $class); |
| 47 |
|
| 48 |
my $swagger = Swagger2->new->load( _getSwagger2Syspref() )->expand; |
| 49 |
$self->{swagger} = $swagger; |
| 50 |
$self->{testContext} = {}; #Collect all objects created for testing here for easy test context tearDown. |
| 51 |
|
| 52 |
return $self; |
| 53 |
} |
| 54 |
|
| 55 |
=head testREST |
| 56 |
Gets and runs all the tests for all endpoints. |
| 57 |
=cut |
| 58 |
|
| 59 |
sub testREST { |
| 60 |
my ($self) = @_; |
| 61 |
my $tests = $self->getTests(); |
| 62 |
$self->runTests($tests); |
| 63 |
} |
| 64 |
|
| 65 |
=head getTests |
| 66 |
Reads through the Swagger2 API definition and prepares tests for each |
| 67 |
returned HTTP status code within |
| 68 |
each accepted HTTP verb within |
| 69 |
each defined API endpoint/url |
| 70 |
|
| 71 |
This enforces that no documented case is left untested. |
| 72 |
If the 'testKeywords'-parameter is given during object instantiation, filters endpoints |
| 73 |
with the keywords. |
| 74 |
@RETURNS ARRAYRef of RESTTest-objects. |
| 75 |
@THROW Koha::Exception::FeatureUnavailable, if Swagger2 is misconfigured or missing. |
| 76 |
=cut |
| 77 |
|
| 78 |
sub getTests { |
| 79 |
my ($self) = @_; |
| 80 |
|
| 81 |
my $tests = []; |
| 82 |
my $specification = $self->{swagger}->{tree}->{data}; |
| 83 |
my $pathsObject = $specification->{paths}; |
| 84 |
my $basePath = $specification->{basePath}; |
| 85 |
|
| 86 |
my $activeBorrower = _getTestBorrower($self->{testContext}); #The Borrower who authenticates for the tests |
| 87 |
my $apiKey = Koha::ApiKeys->grant($activeBorrower); |
| 88 |
|
| 89 |
#Find the response object which encompasses all documented permutations of API calls. |
| 90 |
foreach my $pathsObject_path (grep {$_ if $_ !~ /^x-/i } sort keys(%$pathsObject)) { #Pick only Path Item Objects |
| 91 |
next unless _testPathsObject_pathAgainstTestKeywords($pathsObject_path, $self->{testKeywords}); |
| 92 |
|
| 93 |
my $pathItemObject = $pathsObject->{$pathsObject_path}; |
| 94 |
foreach my $httpVerb (grep {$_ if $_ =~ /(get|put|post|delete|options|head|patch)/i} sort keys(%$pathItemObject)) { #Pick only Operation Objects |
| 95 |
my $operationObject = $pathItemObject->{$httpVerb}; |
| 96 |
foreach my $httpStatusCode (grep {$_ if $_ !~ /^x-/i } sort keys(%{$operationObject->{responses}})) { #Pick only Response Objects from the Responses Object. |
| 97 |
my $responseObject = $operationObject->{responses}->{$httpStatusCode}; |
| 98 |
|
| 99 |
my $subtest = t::lib::RESTTest->new({basePath => $basePath, |
| 100 |
pathsObjectPath => $pathsObject_path, |
| 101 |
httpVerb => $httpVerb, |
| 102 |
httpStatusCode => $httpStatusCode, |
| 103 |
swagger2specification => $specification, |
| 104 |
operationObject => $operationObject, |
| 105 |
activeBorrower => $activeBorrower, |
| 106 |
apiKey => $apiKey, |
| 107 |
}); |
| 108 |
push @$tests, $subtest; |
| 109 |
} |
| 110 |
} |
| 111 |
} |
| 112 |
@$tests = reverse @$tests; |
| 113 |
return $tests; |
| 114 |
} |
| 115 |
|
| 116 |
=head runTests |
| 117 |
Executes the prepared tests. |
| 118 |
Sets up the necessary authentication prerequisites documented in the Swagger2 API definition |
| 119 |
prior to executing the test subroutine. |
| 120 |
Tears down any preconfigured changes after each test. |
| 121 |
=cut |
| 122 |
|
| 123 |
sub runTests { |
| 124 |
my ($self, $tests) = @_; |
| 125 |
|
| 126 |
print "testREST():> Starting testing >>>\n"; |
| 127 |
my ($driver) = t::lib::WebDriverFactory::getUserAgentDrivers('mojolicious'); |
| 128 |
foreach my $subtest (@$tests) { |
| 129 |
my $testPackageName = $subtest->get_packageName(); |
| 130 |
my $testSubroutineName = $subtest->get_subroutineName(); |
| 131 |
eval "require $testPackageName"; |
| 132 |
if ($@) { |
| 133 |
warn "$@\n"; |
| 134 |
} |
| 135 |
|
| 136 |
if ($@) { #Trigger this test to fail if the package is unimplemented |
| 137 |
ok(0, "No test package defined for API route '".$subtest->get_routePath()."'. You must define it in $testPackageName->$testSubroutineName()."); |
| 138 |
} |
| 139 |
elsif (not("$testPackageName"->can("$testSubroutineName"))) { |
| 140 |
ok(0, "No test subroutine defined for API route '".$subtest->get_routePath()."'. You must define it in $testPackageName->$testSubroutineName()."); |
| 141 |
} |
| 142 |
else { |
| 143 |
$self->_prepareTestContext( $subtest, $driver ); |
| 144 |
|
| 145 |
eval { #Prevent propagation of death from above, so we can continue testing and clean up the test context afterwards. |
| 146 |
no strict 'refs'; |
| 147 |
subtest "$testPackageName->$testSubroutineName()" => sub { |
| 148 |
"$testPackageName"->$testSubroutineName( $subtest, $driver ); |
| 149 |
}; |
| 150 |
}; |
| 151 |
if ($@) { |
| 152 |
warn "$@\n"; |
| 153 |
} |
| 154 |
|
| 155 |
$self->_tearDownTestContext( $subtest, $driver ); |
| 156 |
} |
| 157 |
} |
| 158 |
t::lib::TestObjects::ObjectFactory->tearDownTestContext($self->{testContext}); #Clear the global REST test context. |
| 159 |
done_testing; |
| 160 |
} |
| 161 |
|
| 162 |
sub _getSwagger2Syspref { |
| 163 |
my $swagger2DefinitionLocation = $ENV{KOHA_PATH}.'/api/v1/swagger.json'; |
| 164 |
unless (-f $swagger2DefinitionLocation) { |
| 165 |
Koha::Exception::FeatureUnavailable->throw(error => "Swagger2TestRunner():> Couldn't find the Swagger2 definitions file from '$swagger2DefinitionLocation'. You must have a swagger.json-file to use the test runner."); |
| 166 |
} |
| 167 |
return $swagger2DefinitionLocation; |
| 168 |
} |
| 169 |
|
| 170 |
=head _getTestBorrower |
| 171 |
Gets the universal active test Borrower used in all REST tests as the Borrower consuming the API. |
| 172 |
=cut |
| 173 |
|
| 174 |
sub _getTestBorrower { |
| 175 |
my ($testContext) = @_; |
| 176 |
my $borrowerFactory = t::lib::TestObjects::BorrowerFactory->new(); |
| 177 |
my $borrowers = $borrowerFactory->createTestGroup([ |
| 178 |
{firstname => 'TestRunner', |
| 179 |
surname => 'AI', |
| 180 |
cardnumber => '11A000', |
| 181 |
branchcode => 'CPL', |
| 182 |
address => 'Technological Singularity', |
| 183 |
city => 'Gehenna', |
| 184 |
zipcode => '80140', |
| 185 |
email => 'bionicman@example.com', |
| 186 |
categorycode => 'PT', |
| 187 |
dateofbirth => DateTime->now(time_zone => C4::Context->tz())->subtract(years => 21)->iso8601(), #I am always 21! |
| 188 |
}, |
| 189 |
], undef, $testContext); |
| 190 |
return $borrowers->{'11A000'}; |
| 191 |
} |
| 192 |
|
| 193 |
=head _testPathsObject_pathAgainstTestKeywords |
| 194 |
Implements the 'testKeywords'-parameter introduced in the constructor '->new()'. |
| 195 |
=cut |
| 196 |
|
| 197 |
sub _testPathsObject_pathAgainstTestKeywords { |
| 198 |
my ($pathsObject_path, $keywords) = @_; |
| 199 |
return 1 unless $keywords; |
| 200 |
foreach my $kw (@$keywords) { |
| 201 |
my ($exclude, $include, $only); |
| 202 |
if ($kw =~ /^\^(.+?)$/) { |
| 203 |
$exclude = $1; |
| 204 |
} |
| 205 |
elsif ($kw =~ /^\@(.+?)$/) { |
| 206 |
$only = $1; |
| 207 |
} |
| 208 |
else { |
| 209 |
$include = $kw; |
| 210 |
} |
| 211 |
|
| 212 |
if ($include) { |
| 213 |
return undef unless $pathsObject_path =~ m/\Q$include/; |
| 214 |
} |
| 215 |
elsif ($exclude) { |
| 216 |
return undef if $pathsObject_path =~ m/\Q$exclude/; |
| 217 |
} |
| 218 |
elsif ($only) { |
| 219 |
return undef unless $pathsObject_path =~ m/^\Q$only\E$/; |
| 220 |
} |
| 221 |
} |
| 222 |
return 1; |
| 223 |
} |
| 224 |
|
| 225 |
=head _prepareTestContext |
| 226 |
Help to make implementing these tests maximally easy. |
| 227 |
Make sure the active test Borrower has proper permissions to access the resource |
| 228 |
and the authentication headers are properly set. |
| 229 |
=cut |
| 230 |
|
| 231 |
sub _prepareTestContext { |
| 232 |
my ($self, $subtest, $driver) = @_; |
| 233 |
my $permissionsRequired = _replaceAnyPermissions( $subtest->get_requiredPermissions() ); |
| 234 |
|
| 235 |
my $permissionManager = Koha::Auth::PermissionManager->new(); |
| 236 |
$permissionManager->grantPermissions($subtest->get_activeBorrower(), $permissionsRequired); |
| 237 |
|
| 238 |
#Prepare authentication headers |
| 239 |
$driver->ua->once(start => sub { #Subscribe only once to this event, we need fresh headers on every HTTP request. |
| 240 |
my ($ua, $tx) = @_; |
| 241 |
my $headers = Koha::Auth::Challenge::RESTV1::prepareAuthenticationHeaders($subtest->get_activeBorrower(), undef, $subtest->get_httpVerb()); |
| 242 |
$tx->req->headers->add('X-Koha-Date' => $headers->{'X-Koha-Date'}); |
| 243 |
$tx->req->headers->add('Authorization' => $headers->{Authorization}); |
| 244 |
}); |
| 245 |
} |
| 246 |
|
| 247 |
=head _tearDownTestContext |
| 248 |
Help to make implementing these tests maximally easy. |
| 249 |
Remove all granted permissions so they wont interfere with other REST tests. |
| 250 |
|
| 251 |
Also purges the test context created during test execution, if the context has been populated with TestObjectFactories. |
| 252 |
=cut |
| 253 |
|
| 254 |
sub _tearDownTestContext { |
| 255 |
my ($self, $subtest, $driver) = @_; |
| 256 |
my $testContext = $subtest->get_testContext(); |
| 257 |
|
| 258 |
my $permissionManager = Koha::Auth::PermissionManager->new(); |
| 259 |
$permissionManager->revokeAllPermissions($subtest->{activeBorrower}); |
| 260 |
|
| 261 |
t::lib::TestObjects::ObjectFactory->tearDownTestContext($testContext); |
| 262 |
|
| 263 |
sleep 1; #Wait for Test::Mojo to clean up and reattach event handlers in it's asynchronous internals. |
| 264 |
} |
| 265 |
|
| 266 |
=head _replaceAnyPermission |
| 267 |
When fulfilling the *-permission, we need to find any permission under the given |
| 268 |
permissionmodule to satisfy the permission requirement. |
| 269 |
=cut |
| 270 |
|
| 271 |
sub _replaceAnyPermissions { |
| 272 |
my ($permissionsRequired) = @_; |
| 273 |
foreach my $module (keys %$permissionsRequired) { |
| 274 |
if ( $permissionsRequired->{$module} eq '*' ) { |
| 275 |
my @permissions = Koha::Auth::Permissions->search({module => $module}); |
| 276 |
$permissionsRequired->{$module} = $permissions[0]->code; |
| 277 |
} |
| 278 |
} |
| 279 |
return $permissionsRequired; |
| 280 |
} |
| 281 |
|
| 282 |
1; |