File Coverage

blib/lib/JMAP/Tester.pm
Criterion Covered Total %
statement 267 322 82.9
branch 44 86 51.1
condition 11 27 40.7
subroutine 49 54 90.7
pod 12 17 70.5
total 383 506 75.6


line stmt bran cond sub pod time code
1             package JMAP::Tester 0.110;
2             # ABSTRACT: a JMAP client made for testing JMAP servers
3              
4 2     2   41753 use v5.20.0;
  2         9  
5 2     2   10 use warnings;
  2         7  
  2         108  
6              
7 2     2   1104 use Moo;
  2         17585  
  2         13  
8              
9 2     2   3356 use experimental 'signatures';
  2         3139  
  2         12  
10              
11 2     2   1443 use Crypt::Misc qw(decode_b64u encode_b64u);
  2         32644  
  2         202  
12 2     2   2614 use Crypt::Mac::HMAC qw(hmac_b64u);
  2         2557  
  2         141  
13 2     2   1023 use Encode qw(encode_utf8);
  2         32909  
  2         308  
14 2     2   1490 use Future;
  2         24540  
  2         60  
15 2     2   765 use HTTP::Request;
  2         17978  
  2         78  
16 2     2   821 use JMAP::Tester::Abort 'abort';
  2         9  
  2         10  
17 2     2   1065 use JMAP::Tester::Logger::Null;
  2         6  
  2         60  
18 2     2   868 use JMAP::Tester::Response;
  2         7  
  2         67  
19 2     2   759 use JMAP::Tester::Result::Auth;
  2         7  
  2         78  
20 2     2   849 use JMAP::Tester::Result::Download;
  2         4  
  2         57  
21 2     2   2079 use JMAP::Tester::Result::Failure;
  2         5  
  2         87  
22 2     2   883 use JMAP::Tester::Result::Logout;
  2         5  
  2         83  
23 2     2   788 use JMAP::Tester::Result::Upload;
  2         6  
  2         78  
24 2     2   11 use Module::Runtime ();
  2         3  
  2         36  
25 2     2   5 use Params::Util qw(_HASH0 _ARRAY0);
  2         4  
  2         107  
26 2     2   831 use Safe::Isa;
  2         955  
  2         220  
27 2     2   11 use URI;
  2         2  
  2         55  
28 2     2   662 use URI::QueryParam;
  2         244  
  2         55  
29 2     2   9 use URI::Escape qw(uri_escape);
  2         2  
  2         90  
30              
31 2     2   7 use namespace::clean;
  2         3  
  2         10  
32              
33             #pod =head1 OVERVIEW
34             #pod
35             #pod B This library is in its really early days, so use it with that in
36             #pod mind.
37             #pod
38             #pod JMAP::Tester is for testing JMAP servers. Okay? Okay!
39             #pod
40             #pod JMAP::Tester calls the whole thing you get back from a JMAP server a "response"
41             #pod if it's an HTTP 200. Every JSON Array (of three entries -- go read the spec if
42             #pod you need to!) is called a L. Runs
43             #pod of Sentences with the same client id are called
44             #pod L.
45             #pod
46             #pod You use the test client like this:
47             #pod
48             #pod my $jtest = JMAP::Tester->new({
49             #pod api_uri => 'https://jmap.local/account/123',
50             #pod });
51             #pod
52             #pod my $response = $jtest->request([
53             #pod [ 'Mailbox/get' => {} ],
54             #pod [ 'Email/changes' => { sinceState => "123" } ],
55             #pod ]);
56             #pod
57             #pod # This returns two Paragraph objects if there are exactly two paragraphs.
58             #pod # Otherwise, it throws an exception.
59             #pod my ($mbx_p, $msg_p) = $response->assert_n_paragraphs(2);
60             #pod
61             #pod # These get the single Sentence of each paragraph, asserting that there is
62             #pod # exactly one Sentence in each Paragraph, and that it's of the given type.
63             #pod my $mbx = $mbx_p->single('Mailbox/get');
64             #pod my $msg = $msg_p->single('Email/changes');
65             #pod
66             #pod is( @{ $mbx->arguments->{list} }, 10, "we expect 10 mailboxes");
67             #pod ok( ! $msg->arguments->{hasMoreChanges}, "we got all the msg updates needed");
68             #pod
69             #pod By default, all the structures returned have been passed through
70             #pod L, so you may want to strip their type data before using normal
71             #pod Perl code on them. You can do that with:
72             #pod
73             #pod my $struct = $response->as_triples; # gets the complete JSON data
74             #pod $jtest->strip_json_types( $struct ); # strips all the JSON::Typist types
75             #pod
76             #pod Or more simply:
77             #pod
78             #pod my $struct = $response->as_stripped_triples;
79             #pod
80             #pod There is also L.
81             #pod
82             #pod =cut
83              
84             #pod =attr should_return_futures
85             #pod
86             #pod If true, this indicates that the various network-accessing methods should
87             #pod return L objects rather than immediate results.
88             #pod
89             #pod =cut
90              
91             has should_return_futures => (
92             is => 'ro',
93             default => 0,
94             );
95              
96             # When something doesn't work — not an individual method call, but the whole
97             # HTTP call, somehow — then the future will fail, and the failure might be a
98             # JMAP tester failure object, meaning we semi-expected it, or it might be some
99             # other crazy failure, meaning we had no way of seeing it coming.
100             #
101             # We use Future->fail because that way we can use ->else in chains to only act
102             # on successful HTTP calls. At the end, it's fine if you're expecting a future
103             # and can know that a failed future is a fail and a done future is okay. In the
104             # old calling convention, though, you expect to get a success/fail object as
105             # long as you got an HTTP response. Otherwise, you'd get an exception.
106             #
107             # $Failsafe emulates that. Just before we return from a future-returning
108             # method, and if the client is not set to return futures, we do this:
109             #
110             # * successful futures return their payload, the Result object
111             # * failed futures that contain a JMAP tester Failure return the failure
112             # * other failed futures die, like they would if you called $failed_future->get
113             my $Failsafe = sub {
114             $_[0]->else_with_f(sub {
115             my ($f, $fail) = @_;
116             return $fail->$_isa('JMAP::Tester::Result::Failure') ? Future->done($fail)
117             : $f;
118             });
119             };
120              
121             #pod =attr json_pretty
122             #pod
123             #pod This is a boolean which, if true, will cause the JMAP::Tester object to
124             #pod canonicalize and pretty-print generated JSON.
125             #pod
126             #pod =cut
127              
128             has json_pretty => (
129             is => 'ro',
130             default => 0,
131             );
132              
133             #pod =attr json_codec
134             #pod
135             #pod This is an object that implements the JSON API, roughly meaning that it
136             #pod provides C and C methods. You probably won't need to use
137             #pod anything but the default version outside of exceptional cases.
138             #pod
139             #pod =cut
140              
141             has json_codec => (
142             is => 'bare',
143             reader => '_json_codec',
144             lazy => 1,
145             handles => {
146             json_decode => 'decode',
147             },
148             default => sub {
149             my ($self) = @_;
150              
151             require JSON::XS;
152             my $json = JSON::XS->new->utf8->convert_blessed;
153              
154             if ($self->json_pretty) {
155             $json = $json->pretty->canonical;
156             }
157             return $json;
158             },
159             );
160              
161 4     4 0 6 sub json_encode ($self, $data) {
  4         4  
  4         5  
  4         5  
162 4 100       14 if ($data->$_isa('JMAP::Tester::JSONLiteral')) {
163 1         17 return $data->bytes;
164             }
165              
166 3         85 $self->_json_codec->encode($data);
167             }
168              
169             #pod =attr use_json_typists
170             #pod
171             #pod This attribute governs the conversion of JSON data into typed objects, using
172             #pod L. This attribute is true by default.
173             #pod
174             #pod =cut
175              
176             has use_json_typist => (
177             is => 'ro',
178             default => 1,
179             );
180              
181             has _json_typist => (
182             is => 'ro',
183             handles => {
184             strip_json_types => 'strip_types',
185             },
186             default => sub {
187             require JSON::Typist;
188             return JSON::Typist->new;
189             },
190             );
191              
192 9     9 0 233 sub apply_json_types ($self, $data) {
  9         12  
  9         12  
  9         7  
193 9 100       29 return $data unless $self->use_json_typist;
194 8         39 return $self->_json_typist->apply_types($data);
195             }
196              
197             for my $type (qw(api authentication download upload)) {
198             has "$type\_uri" => (
199             is => 'rw',
200             predicate => "has_$type\_uri",
201             clearer => "clear_$type\_uri",
202             );
203             }
204              
205             has ua => (
206             is => 'ro',
207             default => sub {
208             require JMAP::Tester::UA::LWP;
209             JMAP::Tester::UA::LWP->new;
210             },
211             );
212              
213             #pod =attr ident
214             #pod
215             #pod This is a (preferably short) string that can be used to identify this
216             #pod JMAP::Tester object. It's used in request logging, so that client telemetry
217             #pod can be correlated to a specific client object. If no ident is given, an ident
218             #pod will be generated, but it is not guaranteed to be unique.
219             #pod
220             #pod =cut
221              
222             has ident => (
223             is => 'ro',
224             default => sub {
225             my ($self) = @_;
226             state $ident_counter = 0;
227             return "jmap-tester-" . (++$ident_counter);
228             }
229             );
230              
231             #pod =attr default_using
232             #pod
233             #pod This is an arrayref of strings that specify which capabilities the client
234             #pod wishes to use. (See L
235             #pod for more info). By default, JMAP::Tester will not send a 'using' parameter.
236             #pod
237             #pod =cut
238              
239             has default_using => (
240             is => 'rw',
241             predicate => '_has_default_using',
242             );
243              
244             #pod =attr default_arguments
245             #pod
246             #pod This is a hashref of arguments to be put into each method call. It's
247             #pod especially useful for setting a default C. Values given in methods
248             #pod passed to C will override defaults. If the value is a reference to
249             #pod C, then no value will be passed for that key.
250             #pod
251             #pod In other words, in this situation:
252             #pod
253             #pod my $tester = JMAP::Tester->new({
254             #pod ...,
255             #pod default_arguments => { a => 1, b => 2, c => 3 },
256             #pod });
257             #pod
258             #pod $tester->request([
259             #pod [ eatPies => { a => 100, b => \undef } ],
260             #pod ]);
261             #pod
262             #pod The request will effectively be:
263             #pod
264             #pod [ [ "eatPies", { "a": 100, "c": 3 }, "a" ] ]
265             #pod
266             #pod =cut
267              
268             has default_arguments => (
269             is => 'rw',
270             default => sub { {} },
271             );
272              
273             #pod =attr accounts
274             #pod
275             #pod This method will return a list of pairs mapping accountIds to accounts
276             #pod as provided by the client session object if any have been configured.
277             #pod
278             #pod =cut
279              
280             has _accounts => (
281             is => 'rw',
282             init_arg => undef,
283             predicate => '_has_accounts',
284             );
285              
286 1     1 1 9847 sub accounts ($self) {
  1         2  
  1         1  
287 1 50       6 return unless $self->_has_accounts;
288 1         1 return %{ $self->_accounts }
  1         7  
289             }
290              
291             #pod =method primary_account_for
292             #pod
293             #pod my $account_id = $tester->primary_account_for($using);
294             #pod
295             #pod This returns the primary accountId to be used for the given capability, or
296             #pod undef if none is available. This is only useful if the tester has been
297             #pod configured from a client session.
298             #pod
299             #pod =cut
300              
301             has _primary_accounts => (
302             is => 'rw',
303             init_arg => undef,
304             predicate => '_has_primary_accounts',
305             );
306              
307 4     4 1 8080 sub primary_account_for ($self, $using) {
  4         8  
  4         7  
  4         6  
308 4 50       22 return unless $self->_has_primary_accounts;
309 4         35 return $self->_primary_accounts->{ $using };
310             }
311              
312             #pod =method request
313             #pod
314             #pod my $result = $jtest->request([
315             #pod [ methodOne => { ... } ],
316             #pod [ methodTwo => { ... } ],
317             #pod ]);
318             #pod
319             #pod This method accepts either an arrayref of method calls or a hashref with a
320             #pod C key. It sends the calls to the JMAP server and returns a
321             #pod result.
322             #pod
323             #pod For each method call, if there's a third element (a I) then it's
324             #pod left as-is. If no client id is given, one is generated. You can mix explicit
325             #pod and autogenerated client ids. They will never conflict.
326             #pod
327             #pod The arguments to methods are JSON-encoded with a L-aware encoder,
328             #pod so JSON::Typist types can be used to ensure string or number types in the
329             #pod generated JSON. If an argument is a reference to C, it will be removed
330             #pod before the method call is made. This lets you override a default by omission.
331             #pod
332             #pod The return value is an object that does the L role,
333             #pod meaning it's got an C method that returns true or false. For now,
334             #pod at least, failures are L objects. More refined
335             #pod failure objects may exist in the future. Successful requests return
336             #pod L objects.
337             #pod
338             #pod Before the JMAP request is made, each triple is passed to a method called
339             #pod C, which can tweak the method however it likes.
340             #pod
341             #pod This method respects the C attributes of the
342             #pod JMAP::Tester object, and in futures mode will return a future that will resolve
343             #pod to the Result.
344             #pod
345             #pod =cut
346              
347 4     4 1 3838 sub request ($self, $input_request) {
  4         6  
  4         8  
  4         5  
348 4 50       18 Carp::confess("can't perform request: no api_uri configured")
349             unless $self->has_api_uri;
350              
351 4         22 state $ident = 'a';
352 4         8 my %seen;
353             my @suffixed;
354              
355 4         105 my %default_args = %{ $self->default_arguments };
  4         18  
356              
357 4         8 my $request;
358              
359 4 100       17 if ($input_request->$_isa('JMAP::Tester::JSONLiteral')) {
360             # We'll be "encoding" (read: not really encoding) this directly, with no
361             # munging of the content, no creation of call ids, etc.
362 1         25 $request = $input_request;
363             } else {
364 3 50       41 $request = _ARRAY0($input_request)
365             ? { methodCalls => $input_request }
366             : { %$input_request };
367              
368 3         5 for my $call (@{ $request->{methodCalls} }) {
  3         9  
369 3         7 my $copy = [ @$call ];
370 3 50       10 if (defined $copy->[2]) {
371 0         0 $seen{$call->[2]}++;
372             } else {
373 3         5 my $next;
374 3         4 do { $next = $ident++ } until ! $seen{$ident}++;
  3         17  
375 3         8 $copy->[2] = $next;
376             }
377              
378             my %arg = (
379             %default_args,
380 3   50     7 %{ $copy->[1] // {} },
  3         13  
381             );
382              
383 3         10 for my $key (keys %arg) {
384 3 50 66     18 if ( ref $arg{$key}
      33        
385             && ref $arg{$key} eq 'SCALAR'
386 0         0 && ! defined ${ $arg{$key} }
387             ) {
388 0         0 delete $arg{$key};
389             }
390             }
391              
392 3         7 $copy->[1] = \%arg;
393              
394             # Originally, I had a second argument, \%stash, which was the same for the
395             # whole ->request, so you could store data between munges. Removed, for
396             # now, as YAGNI. -- rjbs, 2019-03-04
397 3         11 $self->munge_method_triple($copy);
398              
399 3         7 push @suffixed, $copy;
400             }
401              
402 3         6 $request->{methodCalls} = \@suffixed;
403              
404             $request = $request->{methodCalls}
405 3 50 33     9 if $ENV{JMAP_TESTER_NO_WRAPPER} && _ARRAY0($input_request);
406              
407 3 50 33     11 if ($self->_has_default_using && ! exists $request->{using}) {
408 0         0 $request->{using} = $self->default_using;
409             }
410             }
411              
412 4         12 my $json = $self->json_encode($request);
413              
414 4         37 my $post = HTTP::Request->new(
415             POST => $self->api_uri,
416             [
417             'Content-Type' => 'application/json',
418             $self->_maybe_auth_header,
419             ],
420             $json,
421             );
422              
423 4         648 my $res_f = $self->ua->request($self, $post, jmap => {
424             jmap_array => \@suffixed,
425             json => $json,
426             });
427              
428             my $future = $res_f->then(sub {
429 4     4   202 my ($res) = @_;
430              
431 4 50       14 unless ($res->is_success) {
432 0         0 $self->_logger->log_jmap_response($self, { http_response => $res });
433 0         0 return Future->fail(
434             JMAP::Tester::Result::Failure->new({
435             http_response => $res,
436             diagnostic_dumper => $self->default_diagnostic_dumper,
437             })
438             );
439             }
440              
441 4         37 return Future->done($self->_jresponse_from_hresponse($res));
442 4         82 });
443              
444 4 50       125 return $self->should_return_futures ? $future : $future->$Failsafe->get;
445             }
446              
447       3 0   sub munge_method_triple {}
448              
449 8     8 0 157 sub response_class { 'JMAP::Tester::Response' }
450              
451 8     8   4380 sub _jresponse_from_hresponse ($self, $http_res) {
  8         16  
  8         9  
  8         10  
452             # TODO check that it's really application/json!
453 8         31 my $json = $http_res->decoded_content;
454              
455 8         1151 my $data = $self->apply_json_types( $self->json_decode( $json ) );
456              
457 8         924 my ($items, $props);
458 8 100       25 if (_HASH0($data)) {
    50          
459 7         90 $props = $data;
460 7         15 $items = $props->{methodResponses};
461             } elsif (_ARRAY0($data)) {
462 1         1 $props = {};
463 1         2 $items = $data;
464             } else {
465 0         0 abort("illegal response to JMAP request: $data");
466             }
467              
468 8         50 $self->_logger->log_jmap_response(
469             $self,
470             {
471             jmap_array => $items,
472             json => $json,
473             http_response => $http_res,
474             }
475             );
476              
477 8         24 return $self->response_class->new({
478             items => $items,
479             http_response => $http_res,
480             wrapper_properties => $props,
481             diagnostic_dumper => $self->default_diagnostic_dumper,
482             });
483             }
484              
485             has _logger => (
486             is => 'ro',
487             default => sub {
488             if ($ENV{JMAP_TESTER_LOGGER}) {
489             my ($class, $filename) = split /:/, $ENV{JMAP_TESTER_LOGGER};
490             $class = "JMAP::Tester::Logger::$class";
491             Module::Runtime::require_module($class);
492              
493             return $class->new({
494             writer => $filename // 'jmap-tester-{T}-{PID}.log'
495             });
496             }
497              
498             JMAP::Tester::Logger::Null->new({ writer => \undef });
499             },
500             );
501              
502             #pod =method upload
503             #pod
504             #pod my $result = $tester->upload(\%arg);
505             #pod
506             #pod Required arguments are:
507             #pod
508             #pod accountId - the account for which we're uploading (no default)
509             #pod type - the content-type we want to provide to the server
510             #pod blob - the data to upload. Must be a reference to a string
511             #pod
512             #pod This uploads the given blob.
513             #pod
514             #pod The return value will either be a L
515             #pod object|JMAP::Tester::Result::Failure> or an L
516             #pod result|JMAP::Tester::Result::Upload>.
517             #pod
518             #pod This method respects the C attributes of the
519             #pod JMAP::Tester object, and in futures mode will return a future that will resolve
520             #pod to the Result.
521             #pod
522             #pod =cut
523              
524 1     1 1 4 sub upload ($self, $arg) {
  1         3  
  1         2  
  1         2  
525             # TODO: support blob as handle or sub -- rjbs, 2016-11-17
526              
527 1         6 my $uri = $self->upload_uri;
528              
529 1 50       6 Carp::confess("can't upload without upload_uri")
530             unless $uri;
531              
532 1         4 for my $param (qw(accountId type blob)) {
533 3         10 my $value = $arg->{ $param };
534              
535 3 50       7 Carp::confess("missing required parameter $param")
536             unless defined $value;
537              
538 3 100       11 if ($param eq 'accountId') {
539 1         33 $uri =~ s/\{$param\}/$value/g;
540             }
541             }
542              
543             my $post = HTTP::Request->new(
544             POST => $uri,
545             [
546             'Content-Type' => $arg->{type},
547             $self->_maybe_auth_header,
548             ],
549 1         6 ${ $arg->{blob} },
  1         9  
550             );
551              
552 1         223 my $res_f = $self->ua->request($self, $post, 'upload');
553              
554             my $future = $res_f->then(sub {
555 1     1   74 my ($res) = @_;
556              
557 1 50       4 unless ($res->is_success) {
558 0         0 $self->_logger->log_upload_response($self, { http_response => $res });
559 0         0 return Future->fail(
560             JMAP::Tester::Result::Failure->new({
561             http_response => $res,
562             diagnostic_dumper => $self->default_diagnostic_dumper,
563             })
564             );
565             }
566              
567 1         11 my $json = $res->decoded_content;
568 1         128 my $blob = $self->apply_json_types( $self->json_decode( $json ) );
569              
570 1         78 $self->_logger->log_upload_response(
571             $self,
572             {
573             json => $json,
574             blob_struct => $blob,
575             http_response => $res,
576             }
577             );
578              
579 1         12 return Future->done(
580             JMAP::Tester::Result::Upload->new({
581             http_response => $res,
582             payload => $blob,
583             diagnostic_dumper => $self->default_diagnostic_dumper,
584             })
585             );
586 1         21 });
587              
588 1 50       1148 return $self->should_return_futures ? $future : $future->$Failsafe->get;
589             }
590              
591             #pod =method download
592             #pod
593             #pod my $result = $tester->download(\%uri_arg, \%other_arg);
594             #pod
595             #pod The first hashref provides values that go into the download URI:
596             #pod
597             #pod blobId - the blob to download (no default)
598             #pod accountId - the account for which we're downloading (no default)
599             #pod type - the content-type we want the server to provide back (no default)
600             #pod name - the name we want the server to provide back (default: "download")
601             #pod
602             #pod If the download URI template has a C, C, or C
603             #pod placeholder but no argument for that is given to C, an exception
604             #pod will be thrown.
605             #pod
606             #pod The second hashref, which is optional, provides other arguments to the method.
607             #pod Right now, there is only one, B. The argument is only here
608             #pod for legacy purposes, specifically for the Cyrus IMAP project, and may be
609             #pod removed B.
610             #pod
611             #pod accept - the value of the Accept header to use when downloading
612             #pod
613             #pod The return value will either be a L
614             #pod object|JMAP::Tester::Result::Failure> or an L
615             #pod result|JMAP::Tester::Result::Download>.
616             #pod
617             #pod This method respects the C attributes of the
618             #pod JMAP::Tester object, and in futures mode will return a future that will resolve
619             #pod to the Result.
620             #pod
621             #pod =cut
622              
623             my %DL_DEFAULT = (name => 'download');
624              
625 0     0   0 sub _jwt_sub_param_from_uri ($self, $to_sign) {
  0         0  
  0         0  
  0         0  
626 0         0 "$to_sign";
627             }
628              
629 1     1 0 2 sub download_uri_for ($self, $arg) {
  1         2  
  1         3  
  1         2  
630 1 50       7 Carp::confess("can't compute download URI without configured download_uri")
631             unless my $uri = $self->download_uri;
632              
633 1         4 for my $param (qw(blobId accountId name type)) {
634 4 50       82 next unless $uri =~ /\{$param\}/;
635 4   33     19 my $value = $arg->{ $param } // $DL_DEFAULT{ $param };
636              
637 4 50       10 Carp::confess("missing required template parameter $param")
638             unless defined $value;
639              
640 4 100       12 if ($param eq 'name') {
641 1         9 $value = uri_escape($value);
642             }
643              
644 4         162 $uri =~ s/\{$param\}/$value/g;
645             }
646              
647 1 50       6 if (my $jwtc = $self->_get_jwt_config) {
648 0         0 my $to_get = URI->new($uri);
649 0         0 my $to_sign = $to_get->clone->canonical;
650              
651 0         0 $to_sign->query(undef);
652              
653 0         0 my $header = encode_b64u( $self->json_encode({
654             alg => 'HS256',
655             typ => 'JWT',
656             }) );
657              
658 0         0 my $iat = time;
659 0         0 $iat = $iat - ($iat % 3600);
660              
661             my $payload = encode_b64u( $self->json_encode({
662             iss => $jwtc->{signingId},
663 0         0 iat => $iat,
664             sub => $self->_jwt_sub_param_from_uri($to_sign),
665             }) );
666              
667             my $signature = hmac_b64u(
668             'SHA256',
669 0         0 decode_b64u($jwtc->{signingKey}),
670             "$header.$payload",
671             );
672              
673 0         0 $to_get->query_param(access_token => "$header.$payload.$signature");
674 0         0 $uri = "$to_get";
675             }
676              
677 1         3 return $uri;
678             }
679              
680 1     1 1 3 sub download ($self, $uri_arg, $arg = undef) {
  1         3  
  1         3  
  1         3  
  1         2  
681 1   50     8 $arg //= {};
682              
683 1         6 my $uri = $self->download_uri_for($uri_arg);
684              
685             my $get = HTTP::Request->new(
686             GET => $uri,
687             [
688             $self->_maybe_auth_header,
689 1 50       5 ($arg->{accept} ? (Accept => $arg->{accept}) : ()),
690             ],
691             );
692              
693 1         194 my $res_f = $self->ua->request($self, $get, 'download');
694              
695             my $future = $res_f->then(sub {
696 1     1   50 my ($res) = @_;
697              
698 1         8 $self->_logger->log_download_response($self, { http_response => $res });
699              
700 1 50       3 unless ($res->is_success) {
701 0         0 return Future->fail(
702             JMAP::Tester::Result::Failure->new({
703             http_response => $res,
704             diagnostic_dumper => $self->default_diagnostic_dumper,
705             })
706             );
707             }
708              
709 1         17 return Future->done(
710             JMAP::Tester::Result::Download->new({
711             http_response => $res,
712             diagnostic_dumper => $self->default_diagnostic_dumper,
713             })
714             );
715 1         22 });
716              
717 1 50       1327 return $self->should_return_futures ? $future : $future->$Failsafe->get;
718             }
719              
720 9     9   17 sub _maybe_auth_header ($self) {
  9         13  
  9         11  
721 9 50       98 return ($self->_access_token
722             ? (Authorization => "Bearer " . $self->_access_token)
723             : ());
724             }
725              
726             has _jwt_config => (
727             is => 'rw',
728             init_arg => undef,
729             );
730              
731             sub _now_timestamp {
732             # 0 1 2 3 4 5
733 0     0   0 my ($sec, $min, $hour, $mday, $mon, $year) = gmtime;
734 0         0 return sprintf '%04u-%02u-%02uT%02u:%02u:%02uZ',
735             $year + 1900, $mon + 1, $mday,
736             $hour, $min, $sec;
737             }
738              
739 1     1   3 sub _get_jwt_config ($self) {
  1         3  
  1         2  
740 1 50       15 return unless my $jwtc = $self->_jwt_config;
741 0 0       0 return $jwtc unless $jwtc->{signingKeyValidUntil};
742 0 0       0 return $jwtc if $jwtc->{signingKeyValidUntil} gt $self->_now_timestamp;
743              
744 0         0 $self->update_client_session;
745 0 0       0 return unless $jwtc = $self->_jwt_config;
746 0         0 return $jwtc;
747             }
748              
749             has _access_token => (
750             is => 'rw',
751             init_arg => undef,
752             );
753              
754             #pod =method get_client_session
755             #pod
756             #pod $tester->get_client_session;
757             #pod $tester->get_client_session($auth_uri);
758             #pod
759             #pod This method fetches the content at the authentication endpoint.
760             #pod
761             #pod This method respects the C attributes of the
762             #pod JMAP::Tester object, and in futures mode will return a future that will resolve
763             #pod to the L object.
764             #pod
765             #pod =cut
766              
767 3     3   5 sub _get_client_session_future ($self, $auth_uri = undef) {
  3         4  
  3         5  
  3         4  
768 3   66     16 $auth_uri //= $self->authentication_uri;
769              
770 3         23 my $auth_req = HTTP::Request->new(
771             GET => $auth_uri,
772             [
773             $self->_maybe_auth_header,
774             'Accept' => 'application/json',
775             ],
776             );
777              
778             my $future = $self->ua->request($self, $auth_req, 'auth')->then(sub {
779 3     3   257 my ($res) = @_;
780              
781 3 100       17 unless ($res->code == 200) {
782 1         22 return Future->fail(
783             JMAP::Tester::Result::Failure->new({
784             ident => 'failure to get updated authentication data',
785             http_response => $res,
786             diagnostic_dumper => $self->default_diagnostic_dumper,
787             })
788             );
789             }
790              
791 2         28 my $client_session = $self->json_decode( $res->decoded_content );
792              
793 2         294 my $auth = JMAP::Tester::Result::Auth->new({
794             http_response => $res,
795             client_session => $client_session,
796             diagnostic_dumper => $self->default_diagnostic_dumper,
797             });
798              
799 2         1219 return Future->done($auth);
800 3         7434 });
801             }
802              
803 2     2 1 17778 sub get_client_session ($self, $auth_uri = undef) {
  2         4  
  2         3  
  2         3  
804 2         8 my $future = $self->_get_client_session_future($auth_uri);
805 2 50       1274 return $self->should_return_futures ? $future : $future->$Failsafe->get;
806             }
807              
808             #pod =method update_client_session
809             #pod
810             #pod $tester->update_client_session;
811             #pod $tester->update_client_session($auth_uri);
812             #pod
813             #pod This method fetches the content at the authentication endpoint and uses it to
814             #pod configure the tester's target URIs and signing keys.
815             #pod
816             #pod This method respects the C attributes of the
817             #pod JMAP::Tester object, and in futures mode will return a future that will resolve
818             #pod to the Result.
819             #pod
820             #pod =cut
821              
822 1     1 1 1633 sub update_client_session ($self, $auth_uri = undef) {
  1         3  
  1         1  
  1         2  
823             my $future = $self->_get_client_session_future($auth_uri)->then(sub {
824 1     1   81 my ($auth) = @_;
825              
826 1         6 $self->configure_from_client_session($auth->client_session);
827              
828 1         3 return Future->done($auth);
829 1         5 });
830              
831 1 50       33 return $self->should_return_futures ? $future : $future->$Failsafe->get;
832             }
833              
834             #pod =method configure_from_client_session
835             #pod
836             #pod $tester->configure_from_client_session($client_session);
837             #pod
838             #pod Given a client session object (like those stored in an Auth result), this
839             #pod reconfigures the testers access token, signing keys, URIs, and so forth. This
840             #pod method is used internally when logging in.
841             #pod
842             #pod =cut
843              
844 1     1 1 2 sub configure_from_client_session ($self, $client_session) {
  1         2  
  1         2  
  1         2  
845             # It's not crazy to think that we'd also try to pull the primary accountId
846             # out of the accounts in the auth struct, but I don't think there's a lot to
847             # gain by doing that yet. Maybe later we'd use it to set the default
848             # X-JMAP-AccountId or other things, but I think there are too many open
849             # questions. I'm leaving it out on purpose for now. -- rjbs, 2016-11-18
850              
851             # This is no longer fatal because you might be an anonymous session that
852             # needs to call this to fetch an updated signing key. -- rjbs, 2017-03-23
853             # abort("no accessToken in client session object")
854             # unless $client_session->{accessToken};
855              
856 1         7 $self->_access_token($client_session->{accessToken});
857              
858 1 50 33     5 if ($client_session->{signingId} && $client_session->{signingKey}) {
859             $self->_jwt_config({
860             signingId => $client_session->{signingId},
861             signingKey => $client_session->{signingKey},
862             signingKeyValidUntil => $client_session->{signingKeyValidUntil},
863 0         0 });
864             } else {
865 1         4 $self->_jwt_config(undef);
866             }
867              
868 1         3 for my $type (qw(api download upload)) {
869 3 50       9 if (defined (my $uri = $client_session->{"${type}Url"})) {
870 3         4 my $setter = "$type\_uri";
871 3         8 $self->$setter($uri);
872             } else {
873 0         0 my $clearer = "clear_$type\_uri";
874 0         0 $self->$clearer;
875             }
876             }
877              
878 1         4 $self->_primary_accounts($client_session->{primaryAccounts});
879 1         4 $self->_accounts($client_session->{accounts});
880              
881 1         1 return;
882             }
883              
884             #pod =method logout
885             #pod
886             #pod $tester->logout;
887             #pod
888             #pod This method attempts to log out from the server by sending a C request
889             #pod to the authentication URI.
890             #pod
891             #pod This method respects the C attributes of the
892             #pod JMAP::Tester object, and in futures mode will return a future that will resolve
893             #pod to the Result.
894             #pod
895             #pod =cut
896              
897 0     0 1 0 sub logout ($self) {
  0         0  
  0         0  
898             # This is fatal, not a failure return, because it reflects the user screwing
899             # up, not a possible JMAP-related condition. -- rjbs, 2017-02-10
900 0 0       0 Carp::confess("can't logout: no authentication_uri configured")
901             unless $self->has_authentication_uri;
902              
903 0         0 my $req = HTTP::Request->new(
904             DELETE => $self->authentication_uri,
905             [
906             'Content-Type' => 'application/json; charset=utf-8',
907             'Accept' => 'application/json',
908             ],
909             );
910              
911             my $future = $self->ua->request($self, $req, 'auth')->then(sub {
912 0     0   0 my ($res) = @_;
913              
914 0 0       0 if ($res->code == 204) {
915 0         0 $self->_access_token(undef);
916              
917 0         0 return Future->done(
918             JMAP::Tester::Result::Logout->new({
919             http_response => $res,
920             })
921             );
922             }
923              
924 0         0 return Future->fail(
925             JMAP::Tester::Result::Failure->new({
926             ident => "failed to log out",
927             http_response => $res,
928             diagnostic_dumper => $self->default_diagnostic_dumper,
929             })
930             );
931 0         0 });
932              
933 0 0       0 return $self->should_return_futures ? $future : $future->$Failsafe->get;
934             }
935              
936             #pod =method http_request
937             #pod
938             #pod my $response = $jtest->http_request($http_request);
939             #pod
940             #pod Sometimes, you may need to make an HTTP request with your existing web
941             #pod connection. This might be to interact with a custom authentication mechanism,
942             #pod to access custom endpoints, or just to make very, very specifically crafted
943             #pod requests. For this reasons, C exists.
944             #pod
945             #pod Pass this method an L and it will use the tester's UA object to
946             #pod make the request.
947             #pod
948             #pod This method respects the C attributes of the
949             #pod JMAP::Tester object, and in futures mode will return a future that will resolve
950             #pod to the L.
951             #pod
952             #pod =cut
953              
954 1     1 1 2 sub http_request ($self, $http_request) {
  1         2  
  1         1  
  1         15  
955             my $future = $self->ua->request($self, $http_request, 'misc')->then(sub {
956 1     1   64 my ($res) = @_;
957 1         7 $self->_logger->log_misc_response($self, { http_response => $res });
958 1         5 return Future->done($res);
959 1         6 });
960              
961 1 50       28 return $self->should_return_futures ? $future : $future->$Failsafe->get;
962             }
963              
964             #pod =method http_get
965             #pod
966             #pod my $response = $jtest->http_get($url, $headers);
967             #pod
968             #pod This method is just sugar for calling C to make a GET request for
969             #pod the given URL. C<$headers> is an optional arrayref of headers.
970             #pod
971             #pod =cut
972              
973 1     1 1 74 sub http_get ($self, $url, $headers = undef) {
  1         2  
  1         3  
  1         1  
  1         2  
974 1 50       9 my $req = HTTP::Request->new(
975             GET => $url,
976             (defined $headers ? $headers : ()),
977             );
978 1         108 return $self->http_request($req);
979             }
980              
981             #pod =method http_post
982             #pod
983             #pod my $response = $jtest->http_post($url, $body, $headers);
984             #pod
985             #pod This method is just sugar for calling C to make a POST request
986             #pod for the given URL. C<$headers> is an arrayref of headers and C<$body> is the
987             #pod byte string to be passed as the body.
988             #pod
989             #pod =cut
990              
991 0     0 1   sub http_post ($self, $url, $body, $headers = undef) {
  0            
  0            
  0            
  0            
  0            
992 0   0       my $req = HTTP::Request->new(
993             POST => $url,
994             $headers // [],
995             $body,
996             );
997              
998 0           return $self->http_request($req);
999             }
1000              
1001             has default_diagnostic_dumper => (
1002             is => 'ro',
1003             default => sub {
1004             require JSON::MaybeXS;
1005             state $json = JSON::MaybeXS->new->utf8->convert_blessed->pretty->canonical;
1006             sub ($value) { $json->encode($value); }
1007             },
1008             );
1009              
1010             1;
1011              
1012             __END__