File Coverage

blib/lib/Net/Duo/Mock/Agent.pm
Criterion Covered Total %
statement 111 114 97.3
branch 16 20 80.0
condition 8 11 72.7
subroutine 16 16 100.0
pod 3 3 100.0
total 154 164 93.9


line stmt bran cond sub pod time code
1             # Mock LWP::UserAgent for Net::Duo testing.
2             #
3             # This module provides the same interface as LWP::UserAgent, for the methods
4             # that Net::Duo calls, and verifies that the information passed in by Duo is
5             # correct. It can also simulate responses to exercise response handling in
6             # Net::Duo.
7             #
8             # All tests are reported by Test::More, and no effort is made to produce a
9             # predictable number of test results. This means that any calling test
10             # program should probably not specify a plan and instead use done_testing().
11              
12             package Net::Duo::Mock::Agent 1.01;
13              
14 10     10   185235 use 5.014;
  10         39  
15 10     10   61 use strict;
  10         23  
  10         226  
16 10     10   56 use warnings;
  10         24  
  10         325  
17              
18 10     10   51 use Carp qw(croak);
  10         17  
  10         680  
19 10     10   29371 use Digest::SHA qw(hmac_sha1_hex);
  10         38534  
  10         1029  
20 10     10   9093 use Encode qw(decode);
  10         123112  
  10         1913  
21 10     10   6787 use HTTP::Request;
  10         272136  
  10         346  
22 10     10   7685 use HTTP::Response;
  10         66718  
  10         292  
23 10     10   3074 use JSON ();
  10         38805  
  10         226  
24 10     10   7757 use Perl6::Slurp;
  10         16069  
  10         79  
25 10     10   10013 use Test::More;
  10         187227  
  10         88  
26 10     10   3180 use URI::Escape qw(uri_unescape);
  10         22  
  10         11397  
27              
28             ##############################################################################
29             # Mock API
30             ##############################################################################
31              
32             # Verify the signature on the request.
33             #
34             # The signature uses the Basic Authentication Scheme and should use the
35             # integration key as the username and the hash of the call as the password.
36             # This function duplicates the signature and ensures it's correct. All test
37             # results are reported via Test::More functions.
38             #
39             # $self - Net::Duo::Mock::Agent object
40             # $request - HTTP::Request object to verify
41             #
42             # Returns: undef
43             sub _verify_signature {
44 44     44   76 my ($self, $request) = @_;
45 44         199 my $date = $request->header('Date');
46 44         1806 my $method = uc($request->method);
47 44         475 my $host = $self->{api_hostname};
48              
49             # Get the partial URI. We have to strip the scheme and hostname back off
50             # of it again. Verify the scheme and hostname while we're at it.
51 44         140 my $uri = URI->new($request->uri);
52 44         3902 is($uri->scheme, 'https', 'Scheme');
53 44         14003 is($uri->host, $host, 'Hostname');
54 44         12891 my $path = $uri->path;
55              
56             # Get the username and "password" (actually the hash). Verify the
57             # username.
58 44         617 my ($username, $password) = $request->authorization_basic;
59 44         2140 is($username, $self->{integration_key}, 'Username');
60              
61             # If there is request data, sort it for signing purposes.
62 44         12999 my $args;
63 44 100       139 if ($method eq 'GET') {
64 15   100     100 $args = $uri->query // q{};
65             } else {
66 29   100     122 $args = $request->content // q{};
67             }
68 44         708 $args = join(q{&}, sort(split(m{&}xms, $args)));
69              
70             # Generate the hash of the request and check it.
71 44         142 my $data = join("\n", $date, $method, $host, $path, $args);
72 44         503 my $signature = hmac_sha1_hex($data, $self->{secret_key});
73 44         135 is($password, $signature, 'Signature');
74 44         12839 return;
75             }
76              
77             # Given an HTTP::Request, pretend to perform the request and return an
78             # HTTP::Response object. The content of the HTTP::Response object will be
79             # determined by the most recent calls to the testing API. Each request resets
80             # the response. If no response has been configured, throw an exception.
81             #
82             # $self - Net::Duo::Mock::Agent object
83             # $request - HTTP::Request object to verify
84             #
85             # Returns: An HTTP::Response object
86             # Throws: Exception on fatally bad requests or on an unconfigured test
87             sub request {
88 44     44 1 95 my ($self, $request) = @_;
89              
90             # Throw an exception if we got an unexpected call.
91 44 50       163 if (!$self->{expected}) {
92 0         0 croak('saw an unexpected request');
93             }
94              
95             # Verify the signature on the request. We continue even if it doesn't
96             # verify and check the rest of the results.
97 44         140 $self->_verify_signature($request);
98              
99             # Ensure the method and URI match what we expect, and extract the content.
100 44         173 is($request->method, $self->{expected}{method}, 'Method');
101 44         12837 my $uri = $request->uri;
102 44         283 my $content;
103 44 100       126 if ($request->method eq 'GET') {
104 15 100       186 if ($uri =~ s{ [?] (.*) }{}xms) {
105 9         120 $content = $1;
106             } else {
107 6         51 $content = q{};
108             }
109             } else {
110 29   100     354 $content = $request->content // q{};
111             }
112 44         531 is($uri, $self->{expected}{uri}, 'URI');
113              
114             # Decode the content.
115 44   50     12684 my @pairs = split(m{&}xms, $content // q{});
116 44         78 my %content;
117 44         103 for my $pair (@pairs) {
118 69         214 my ($key, $value) = split(m{=}xms, $pair, 2);
119 69         222 $key = decode('UTF-8', uri_unescape($key));
120 69         4368 $value = decode('UTF-8', uri_unescape($value));
121 69         2982 $content{$key} = $value;
122             }
123              
124             # Check the content.
125 44 100       155 if ($self->{expected}{content}) {
126 27         164 is_deeply(\%content, $self->{expected}{content}, 'Content');
127             } else {
128 17         60 is($content, q{}, 'Content');
129             }
130              
131             # Return the configured response and clear state.
132 44         21062 my $response = $self->{expected}{response};
133 44         146 delete $self->{expected};
134 44         537 return $response;
135             }
136              
137             ##############################################################################
138             # Test API
139             ##############################################################################
140              
141             # Constructor for the mock agent. Takes the same arguments as are passed to
142             # the Net::Duo constructor (minus the user_agent argument) so that the mock
143             # knows the expected keys and hostname.
144             #
145             # $class - Class into which to bless the object
146             # $args_ref - Arguments to the Net::Duo constructor
147             # api_hostname - API hostname for the Duo API integration
148             # integration_key - Public key for the Duo API integration
149             # key_file - Path to file with integration information
150             # secret_key - Secret key for the Duo API integration
151             #
152             # Returns: New Net::Duo::Mock::Agent object
153             # Throws: Text exception on failure to read keys
154             sub new {
155 10     10 1 236 my ($class, $args_ref) = @_;
156 10         23 my $self = {};
157              
158             # Load integration information from key_file if set.
159 10         23 my $keys;
160 10 50       45 if ($args_ref->{key_file}) {
161 10         127 my $json = JSON->new()->relaxed(1);
162 10         62 my $key_data = slurp($args_ref->{key_file});
163 10         1881 $keys = $json->decode($key_data);
164             }
165              
166             # Integration data from $args_ref overrides key_file data.
167 10         34 for my $key (qw(api_hostname integration_key secret_key)) {
168 30   33     164 $self->{$key} = $args_ref->{$key} // $keys->{$key};
169             }
170              
171             # Create the JSON decoder that we'll use for subsequent operations.
172 10         99 $self->{json} = JSON->new->utf8(1);
173              
174             # Bless and return the new object.
175 10         39 bless($self, $class);
176 10         48 return $self;
177             }
178              
179             # Configure an expected request and the response to return. Either response
180             # or response_file should be given. If response_file is given, an
181             # HTTP::Response with a status code of 200 and the contents of that file as
182             # the body (Content-Type: application/json).
183             #
184             # $self - Net::Duo::Mock::Agent object
185             # $args_ref - Expected request and response information
186             # method - Expected method of the request
187             # uri - Expected URI of the request without any query string
188             # content - Expected query or post data as reference (may be undef)
189             # response - HTTP::Response object to return to the caller
190             # response_data - Partial data structure to add to generic JSON in response
191             # response_file - File containing JSON to return as a respose
192             #
193             # Returns: undef
194             # Throws: Text exception on invalid parameters
195             # Text exception if response_file is not readable
196             sub expect {
197 44     44 1 13657 my ($self, $args_ref) = @_;
198              
199             # Verify consistency of the arguments.
200 44         134 my @response_args = qw(response response_data response_file);
201 44         87 my $response_count = grep { defined($args_ref->{$_}) } @response_args;
  132         348  
202 44 50       204 if ($response_count < 1) {
    50          
203 0         0 croak('no response, response_data, or response_file specified');
204             } elsif ($response_count > 1) {
205 0         0 croak('too many of response, response_data, and response_file given');
206             }
207              
208             # Build the response object if needed.
209 44         66 my $response;
210 44 100       127 if ($args_ref->{response}) {
211 1         4 $response = $args_ref->{response};
212             } else {
213 43         287 $response = HTTP::Response->new(200, 'Success');
214 43         2276 $response->header('Content-Type', 'application/json');
215 43         2438 my $reply;
216 43 100       142 if (defined($args_ref->{response_data})) {
217 23         48 my $data = $args_ref->{response_data};
218 23         78 $reply = { stat => 'OK', response => $data };
219             } else {
220 20         79 my $contents = slurp($args_ref->{response_file});
221 20         2951 my $data = $self->{json}->decode($contents);
222 20         77 $reply = { stat => 'OK', response => $data };
223             }
224 43         703 $response->content($self->{json}->encode($reply));
225             }
226              
227             # Set the expected information for call verification later.
228             $self->{expected} = {
229             method => uc($args_ref->{method}),
230             uri => 'https://' . $self->{api_hostname} . $args_ref->{uri},
231             content => $args_ref->{content},
232 44         1147 response => $response,
233             };
234 44         143 return;
235             }
236              
237             1;
238             __END__