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; |