]> git.koha-community.org Git - koha.git/blob - t/db_dependent/Auth.t
Bug 36908: (Rmaint follow-up) Fix number of tests
[koha.git] / t / db_dependent / Auth.t
1 #!/usr/bin/perl
2
3 use Modern::Perl;
4
5 use CGI qw ( -utf8 );
6
7 use Test::MockObject;
8 use Test::MockModule;
9 use List::MoreUtils qw/all any none/;
10 use Test::More tests => 22;
11 use Test::Warn;
12 use t::lib::Mocks;
13 use t::lib::TestBuilder;
14
15 use C4::Auth;
16 use C4::Members;
17 use Koha::AuthUtils qw( hash_password );
18 use Koha::DateUtils qw( dt_from_string );
19 use Koha::Database;
20 use Koha::Patrons;
21 use Koha::Auth::TwoFactorAuth;
22
23 BEGIN {
24     use_ok(
25         'C4::Auth',
26         qw( checkauth haspermission track_login_daily checkpw get_template_and_user checkpw_hash get_cataloguing_page_permissions )
27     );
28 }
29
30 my $schema  = Koha::Database->schema;
31 my $builder = t::lib::TestBuilder->new;
32
33 # FIXME: SessionStorage defaults to mysql, but it seems to break transaction
34 # handling
35 t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
36 t::lib::Mocks::mock_preference( 'PrivacyPolicyConsent', '' ); # Disabled
37
38 # To silence useless warnings
39 $ENV{REMOTE_ADDR} = '127.0.0.1';
40
41 $schema->storage->txn_begin;
42
43 subtest 'checkauth() tests' => sub {
44
45     plan tests => 11;
46
47     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => undef } });
48
49     # Mock a CGI object with real userid param
50     my $cgi = Test::MockObject->new();
51     $cgi->mock(
52         'param',
53         sub {
54             my $var = shift;
55             if ( $var eq 'userid' ) { return $patron->userid; }
56         }
57     );
58     $cgi->mock( 'cookie', sub { return; } );
59     $cgi->mock( 'request_method', sub { return 'POST' } );
60
61     my $authnotrequired = 1;
62     my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, $authnotrequired );
63
64     is( $userid, undef, 'checkauth() returns undef for userid if no logged in user (Bug 18275)' );
65
66     my $db_user_id = C4::Context->config('user');
67     my $db_user_pass = C4::Context->config('pass');
68     $cgi = Test::MockObject->new();
69     $cgi->mock( 'cookie', sub { return; } );
70     $cgi->mock( 'param', sub {
71             my ( $self, $param ) = @_;
72             if ( $param eq 'userid' ) { return $db_user_id; }
73             elsif ( $param eq 'password' ) { return $db_user_pass; }
74             else { return; }
75         });
76     $cgi->mock( 'request_method', sub { return 'POST' } );
77     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, $authnotrequired );
78     is ( $userid, undef, 'If DB user is used, it should not be logged in' );
79
80     my $is_allowed = C4::Auth::haspermission( $db_user_id, { can_do => 'everything' } );
81
82     # FIXME This belongs to t/db_dependent/Auth/haspermission.t but we do not want to c/p the pervious mock statements
83     ok( !$is_allowed, 'DB user should not have any permissions');
84
85     subtest 'Prevent authentication when sending credential via GET' => sub {
86
87         plan tests => 2;
88
89         my $patron = $builder->build_object(
90             { class => 'Koha::Patrons', value => { flags => 1 } } );
91         my $password = set_weak_password($patron);
92         $cgi = Test::MockObject->new();
93         $cgi->mock( 'cookie', sub { return; } );
94         $cgi->mock(
95             'param',
96             sub {
97                 my ( $self, $param ) = @_;
98                 if    ( $param eq 'userid' )   { return $patron->userid; }
99                 elsif ( $param eq 'password' ) { return $password; }
100                 else                           { return; }
101             }
102         );
103
104         $cgi->mock( 'request_method', sub { return 'POST' } );
105         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
106         is( $userid, $patron->userid, 'If librarian user is used and password with POST, they should be logged in' );
107
108         $cgi->mock( 'request_method', sub { return 'GET' } );
109         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
110         is( $userid, undef, 'If librarian user is used and password with GET, they should not be logged in' );
111     };
112
113     subtest 'cas_ticket must be empty in session' => sub {
114
115         plan tests => 2;
116
117         my $patron = $builder->build_object(
118             { class => 'Koha::Patrons', value => { flags => 1 } } );
119         my $password = 'password';
120         t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
121         $patron->set_password( { password => $password } );
122         $cgi = Test::MockObject->new();
123         $cgi->mock( 'cookie', sub { return; } );
124         $cgi->mock(
125             'param',
126             sub {
127                 my ( $self, $param ) = @_;
128                 if    ( $param eq 'userid' )   { return $patron->userid; }
129                 elsif ( $param eq 'password' ) { return $password; }
130                 else                           { return; }
131             }
132         );
133
134         $cgi->mock( 'request_method', sub { return 'POST' } );
135         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
136         is( $userid, $patron->userid, 'If librarian user is used and password with POST, they should be logged in' );
137         my $session = C4::Auth::get_session($sessionID);
138         is( $session->param('cas_ticket'), undef );
139
140     };
141
142     subtest 'sessionID should be passed to the template for auth' => sub {
143
144         plan tests => 1;
145
146         subtest 'hit auth.tt' => sub {
147
148             plan tests => 1;
149
150             my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { flags => 0 } } );
151
152             my $password = set_weak_password($patron);
153
154             my $cgi_mock = Test::MockModule->new('CGI');
155             $cgi_mock->mock( 'request_method', sub { return 'POST' } );
156             my $cgi = CGI->new;
157
158             # Simulating the login form submission
159             $cgi->param( 'userid',   $patron->userid );
160             $cgi->param( 'password', $password );
161
162             my ( $userid, $cookie, $sessionID, $flags, $template ) =
163                 C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
164             ok( $template->{VARS}->{sessionID} );
165         };
166     };
167
168     subtest 'Template params tests (password_expired)' => sub {
169
170         plan tests => 1;
171
172         my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
173
174         my $password = set_weak_password($patron);
175         $patron->password_expiration_date( dt_from_string->subtract(days => 1) )->store;
176
177         my $cgi_mock = Test::MockModule->new('CGI');
178         $cgi_mock->mock( 'request_method', sub { return 'POST' } );
179         my $cgi  = CGI->new;
180         # Simulating the login form submission
181         $cgi->param( 'userid',   $patron->userid );
182         $cgi->param( 'password', $password );
183
184         my ( $userid, $cookie, $sessionID, $flags, $template ) = C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
185         is( $template->{VARS}->{password_has_expired}, 1 );
186     };
187
188     subtest 'Reset auth state when changing users' => sub {
189
190         #NOTE: It's easiest to detect this when changing to a non-existent user, since
191         #that should trigger a redirect to login (instead of returning a session cookie)
192         plan tests => 2;
193         my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { flags => undef } } );
194
195         my $session = C4::Auth::get_session();
196         $session->param( 'number',    $patron->id );
197         $session->param( 'id',        $patron->userid );
198         $session->param( 'ip',        '1.2.3.4' );
199         $session->param( 'lasttime',  time() );
200         $session->param( 'interface', 'intranet' );
201         $session->flush;
202         my $sessionID = $session->id;
203         C4::Context->_new_userenv($sessionID);
204
205         my ($return) =
206             C4::Auth::check_cookie_auth( $sessionID, undef, { skip_version_check => 1, remote_addr => '1.2.3.4' } );
207         is( $return, 'ok', 'Patron authenticated' );
208
209         my $mock2 = Test::MockModule->new('CGI');
210         $mock2->mock( 'request_method', 'POST' );
211         $mock2->mock( 'cookie',         sub { return $sessionID; } );    # oversimplified..
212         my $cgi = CGI->new;
213
214         $cgi->param( -name => 'userid',             -value => 'Bond' );
215         $cgi->param( -name => 'password',           -value => 'James Bond' );
216         $cgi->param( -name => 'koha_login_context', -value => 1 );
217         my ( $userid, $cookie, $flags, $template );
218         ( $userid, $cookie, $sessionID, $flags, $template ) =
219             C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
220         is( $template->{VARS}->{loginprompt}, 1, 'Changing to non-existent user causes a redirect to login' );
221     };
222
223     subtest 'While still logged in, relogin with another user' => sub {
224         plan tests => 5;
225
226         my $patron = $builder->build_object({ class => 'Koha::Patrons', value => {} });
227         my $patron2 = $builder->build_object({ class => 'Koha::Patrons', value => {} });
228         # Create 'former' session
229         my $session = C4::Auth::get_session();
230         $session->param( 'number',       $patron->id );
231         $session->param( 'id',           $patron->userid );
232         $session->param( 'ip',           '1.2.3.4' );
233         $session->param( 'lasttime',     time() );
234         $session->param( 'interface',    'opac' );
235         $session->flush;
236         my $previous_sessionID = $session->id;
237         C4::Context->_new_userenv($previous_sessionID);
238
239         my ( $return ) = C4::Auth::check_cookie_auth( $previous_sessionID, undef, { skip_version_check => 1, remote_addr => '1.2.3.4' } );
240         is( $return, 'ok', 'Former session in shape now' );
241
242         my $mock1 = Test::MockModule->new('C4::Auth');
243         $mock1->mock( 'safe_exit', sub {} );
244         my $mock2 = Test::MockModule->new('CGI');
245         $mock2->mock( 'request_method', 'POST' );
246         $mock2->mock( 'cookie', sub { return $previous_sessionID; } ); # oversimplified..
247         my $cgi = CGI->new;
248         my $password = 'Incr3d1blyZtr@ng93$';
249         $patron2->set_password({ password => $password });
250         $cgi->param( -name => 'userid',             -value => $patron2->userid );
251         $cgi->param( -name => 'password',           -value => $password );
252         $cgi->param( -name => 'koha_login_context', -value => 1 );
253         my ( $userid, $cookie, $sessionID, $flags, $template ) =
254             C4::Auth::checkauth( $cgi, 0, {}, 'opac', undef, undef, { do_not_print => 1 } );
255         is( $userid, $patron2->userid, 'Login of patron2 approved' );
256         isnt( $sessionID, $previous_sessionID, 'Did not return previous session ID' );
257         ok( $sessionID, 'New session ID not empty' );
258
259         # Similar situation: Relogin with former session of $patron, new user $patron2 has no permissions
260         $patron2->flags(undef)->store;
261         $session->param( 'number',       $patron->id );
262         $session->param( 'id',           $patron->userid );
263         $session->param( 'interface',    'intranet' );
264         $session->flush;
265         $previous_sessionID = $session->id;
266         C4::Context->_new_userenv($previous_sessionID);
267         $cgi->param( -name => 'userid',             -value => $patron2->userid );
268         $cgi->param( -name => 'password',           -value => $password );
269         $cgi->param( -name => 'koha_login_context', -value => 1 );
270         ( $userid, $cookie, $sessionID, $flags, $template ) =
271             C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
272         is( $template->{VARS}->{nopermission}, 1, 'No permission response' );
273     };
274
275     subtest 'Two-factor authentication' => sub {
276         plan tests => 18;
277
278         my $patron = $builder->build_object(
279             { class => 'Koha::Patrons', value => { flags => 1 } } );
280         my $password = 'password';
281         $patron->set_password( { password => $password } );
282         $cgi = Test::MockObject->new();
283
284         my $otp_token;
285         our ( $logout, $sessionID, $verified );
286         $cgi->mock(
287             'param',
288             sub {
289                 my ( $self, $param ) = @_;
290                 if    ( $param eq 'userid' )    { return $patron->userid; }
291                 elsif ( $param eq 'password' )  { return $password; }
292                 elsif ( $param eq 'otp_token' ) { return $otp_token; }
293                 elsif ( $param eq 'logout.x' )  { return $logout; }
294                 else                            { return; }
295             }
296         );
297         $cgi->mock( 'request_method', sub { return 'POST' } );
298         $cgi->mock( 'cookie', sub { return $sessionID } );
299
300         my $two_factor_auth = Test::MockModule->new( 'Koha::Auth::TwoFactorAuth' );
301         $two_factor_auth->mock( 'verify', sub {$verified} );
302
303         my ( $userid, $cookie, $flags );
304         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
305
306         sub logout {
307             my $cgi = shift;
308             $logout = 1;
309             undef $sessionID;
310             C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
311             $logout = 0;
312         }
313
314         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'disabled' );
315         $patron->auth_method('password')->store;
316         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
317         is( $userid, $patron->userid, 'Succesful login' );
318         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
319         logout($cgi);
320
321         $patron->auth_method('two-factor')->store;
322         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
323         is( $userid, $patron->userid, 'Succesful login' );
324         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
325         logout($cgi);
326
327         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'enabled' );
328         t::lib::Mocks::mock_config('encryption_key', '1234tH1s=t&st');
329         $patron->auth_method('password')->store;
330         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
331         is( $userid, $patron->userid, 'Succesful login' );
332         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
333         logout($cgi);
334
335         $patron->encode_secret('one_secret');
336         $patron->auth_method('two-factor');
337         $patron->store;
338         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
339         is( $userid, $patron->userid, 'Succesful login' );
340         my $session = C4::Auth::get_session($sessionID);
341         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 1, 'Second auth required' );
342
343         # Wrong OTP token
344         $otp_token = "wrong";
345         $verified = 0;
346         $patron->auth_method('two-factor')->store;
347         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
348         is( $userid, $patron->userid, 'Succesful login' );
349         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 1, 'Second auth still required after wrong OTP token' );
350
351         $otp_token = "good";
352         $verified = 1;
353         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
354         is( $userid, $patron->userid, 'Succesful login' );
355         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 0, 'Second auth no longer required if OTP token has been verified' );
356         logout($cgi);
357
358         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'enforced' );
359         $patron->auth_method('password')->store;
360         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
361         is( $userid, $patron->userid, 'Succesful login' );
362         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA-setup'), 1, 'Setup 2FA required' );
363         logout($cgi);
364
365         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'opac' );
366         is( $userid, $patron->userid, 'Succesful login at the OPAC' );
367         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'No second auth required at the OPAC' );
368
369         #
370         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'disabled' );
371         $session = C4::Auth::get_session($sessionID);
372         $session->param('waiting-for-2FA', 1);
373         $session->flush;
374         my ($auth_status, undef ) = C4::Auth::check_cookie_auth($sessionID, undef );
375         is( $auth_status, 'ok', 'User authenticated, pref was disabled, access OK' );
376         $session->param('waiting-for-2FA', 0);
377         $session->param('waiting-for-2FA-setup', 1);
378         $session->flush;
379         ($auth_status, undef ) = C4::Auth::check_cookie_auth($sessionID, undef );
380         is( $auth_status, 'ok', 'User waiting for 2FA setup, pref was disabled, access OK' );
381     };
382
383     subtest 'loggedinlibrary permission tests' => sub {
384
385         plan tests => 3;
386         my $staff_user = $builder->build_object(
387             { class => 'Koha::Patrons', value => { flags => 536870916 } } );
388
389         my $branch = $builder->build_object({ class => 'Koha::Libraries' });
390
391         my $password = set_weak_password($staff_user);
392         my $cgi = Test::MockObject->new();
393         $cgi->mock( 'cookie', sub { return; } );
394         $cgi->mock(
395             'param',
396             sub {
397                 my ( $self, $param ) = @_;
398                 if    ( $param eq 'userid' )   { return $staff_user->userid; }
399                 elsif ( $param eq 'password' ) { return $password; }
400                 elsif ( $param eq 'branch' )   { return $branch->branchcode; }
401                 else                           { return; }
402             }
403         );
404
405         $cgi->mock( 'request_method', sub { return 'POST' } );
406         my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
407         my $sesh = C4::Auth::get_session($sessionID);
408         is( $sesh->param('branch'), $branch->branchcode, "If user has permission, they should be able to choose a branch" );
409
410         $staff_user->flags(4)->store->discard_changes;
411         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
412         $sesh = C4::Auth::get_session($sessionID);
413         is( $sesh->param('branch'), $staff_user->branchcode, "If user has not permission, they should not be able to choose a branch" );
414
415         $staff_user->flags(1)->store->discard_changes;
416         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
417         $sesh = C4::Auth::get_session($sessionID);
418         is( $sesh->param('branch'), $branch->branchcode, "If user is superlibrarian, they should be able to choose a branch" );
419
420     };
421     C4::Context->_new_userenv; # For next tests
422 };
423
424 subtest 'track_login_daily tests' => sub {
425
426     plan tests => 5;
427
428     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
429     my $userid = $patron->userid;
430
431     $patron->lastseen( undef );
432     $patron->store();
433
434     my $cache     = Koha::Caches->get_instance();
435     my $cache_key = "track_login_" . $patron->userid;
436     $cache->clear_from_cache($cache_key);
437
438     t::lib::Mocks::mock_preference( 'TrackLastPatronActivity', '1' );
439
440     is( $patron->lastseen, undef, 'Patron should have not last seen when newly created' );
441
442     C4::Auth::track_login_daily( $userid );
443     $patron->_result()->discard_changes();
444     isnt( $patron->lastseen, undef, 'Patron should have last seen set when TrackLastPatronActivity = 1' );
445
446     sleep(1); # We need to wait a tiny bit to make sure the timestamp will be different
447     my $last_seen = $patron->lastseen;
448     C4::Auth::track_login_daily( $userid );
449     $patron->_result()->discard_changes();
450     is( $patron->lastseen, $last_seen, 'Patron last seen should still be unchanged' );
451
452     $cache->clear_from_cache($cache_key);
453     C4::Auth::track_login_daily( $userid );
454     $patron->_result()->discard_changes();
455     isnt( $patron->lastseen, $last_seen, 'Patron last seen should be changed if we cleared the cache' );
456
457     t::lib::Mocks::mock_preference( 'TrackLastPatronActivity', '0' );
458     $patron->lastseen( undef )->store;
459     $cache->clear_from_cache($cache_key);
460     C4::Auth::track_login_daily( $userid );
461     $patron->_result()->discard_changes();
462     is( $patron->lastseen, undef, 'Patron should still have last seen unchanged when TrackLastPatronActivity = 0' );
463
464 };
465
466 subtest 'no_set_userenv parameter tests' => sub {
467
468     plan tests => 7;
469
470     my $library = $builder->build_object( { class => 'Koha::Libraries' } );
471     my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
472
473     my $password = set_weak_password($patron);
474
475     ok( checkpw( $patron->userid, $password, undef, undef, 1 ), 'checkpw returns true' );
476     is( C4::Context->userenv, undef, 'Userenv should be undef as required' );
477     C4::Context->_new_userenv('DUMMY SESSION');
478     C4::Context->set_userenv(0,0,0,'firstname','surname', $library->branchcode, 'Library 1', 0, '', '');
479     is( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv gives correct branch' );
480     ok( checkpw( $patron->userid, $password, undef, undef, 1 ), 'checkpw returns true' );
481     is( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv branch is preserved if no_set_userenv is true' );
482     ok( checkpw( $patron->userid, $password, undef, undef, 0 ), 'checkpw still returns true' );
483     isnt( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv branch is overwritten if no_set_userenv is false' );
484 };
485
486 subtest 'checkpw lockout tests' => sub {
487
488     plan tests => 5;
489
490     my $library = $builder->build_object( { class => 'Koha::Libraries' } );
491     my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
492     my $password = set_weak_password($patron);
493     t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 1 );
494
495     my ( $checkpw, undef, undef ) = checkpw( $patron->cardnumber, $password, undef, undef, 1 );
496     ok( $checkpw, 'checkpw returns true with right password when logging in via cardnumber' );
497     ( $checkpw, undef, undef ) = checkpw( $patron->userid, "wrong_password", undef, undef, 1 );
498     is( $checkpw, 0, 'checkpw returns false when given wrong password' );
499     $patron = $patron->get_from_storage;
500     is( $patron->account_locked, 1, "Account is locked from failed login");
501     ( $checkpw, undef, undef ) = checkpw( $patron->userid, $password, undef, undef, 1 );
502     is( $checkpw, undef, 'checkpw returns undef with right password when account locked' );
503     ( $checkpw, undef, undef ) = checkpw( $patron->cardnumber, $password, undef, undef, 1 );
504     is( $checkpw, undef, 'checkpw returns undefwith right password when logging in via cardnumber if account locked' );
505
506 };
507
508 # get_template_and_user tests
509
510 subtest 'get_template_and_user' => sub {   # Tests for the language URL parameter
511
512     sub MockedCheckauth {
513         my ($query,$authnotrequired,$flagsrequired,$type) = @_;
514         # return vars
515         my $userid = 'cobain';
516         my $sessionID = 234;
517         # we don't need to bother about permissions for this test
518         my $flags = {
519             superlibrarian    => 1, acquisition       => 0,
520             borrowers         => 0,
521             catalogue         => 1, circulate         => 0,
522             coursereserves    => 0, editauthorities   => 0,
523             editcatalogue     => 0,
524             parameters        => 0, permissions       => 0,
525             plugins           => 0, reports           => 0,
526             reserveforothers  => 0, serials           => 0,
527             staffaccess       => 0, tools             => 0,
528             updatecharges     => 0
529         };
530
531         my $session_cookie = $query->cookie(
532             -name => 'CGISESSID',
533             -value    => 'nirvana',
534             -HttpOnly => 1
535         );
536
537         return ( $userid, [ $session_cookie ], $sessionID, $flags );
538     }
539
540     # Mock checkauth, build the scenario
541     my $auth = Test::MockModule->new( 'C4::Auth' );
542     $auth->mock( 'checkauth', \&MockedCheckauth );
543
544     # Make sure 'EnableOpacSearchHistory' is set
545     t::lib::Mocks::mock_preference('EnableOpacSearchHistory',1);
546     # Enable es-ES for the OPAC and staff interfaces
547     t::lib::Mocks::mock_preference('OPACLanguages','en,es-ES');
548     t::lib::Mocks::mock_preference('language','en,es-ES');
549
550     # we need a session cookie
551     $ENV{"SERVER_PORT"} = 80;
552     $ENV{"HTTP_COOKIE"} = 'CGISESSID=nirvana';
553
554     my $query = CGI->new;
555     $query->param('language','es-ES');
556
557     my ( $template, $loggedinuser, $cookies ) = get_template_and_user(
558         {
559             template_name   => "about.tt",
560             query           => $query,
561             type            => "opac",
562             authnotrequired => 1,
563             flagsrequired   => { catalogue => 1 },
564             debug           => 1
565         }
566     );
567
568     ok ( ( all { ref($_) eq 'CGI::Cookie' } @$cookies ),
569             'BZ9735: the cookies array is flat' );
570
571     # new query, with non-existent language (we only have en and es-ES)
572     $query->param('language','tomas');
573
574     ( $template, $loggedinuser, $cookies ) = get_template_and_user(
575         {
576             template_name   => "about.tt",
577             query           => $query,
578             type            => "opac",
579             authnotrequired => 1,
580             flagsrequired   => { catalogue => 1 },
581             debug           => 1
582         }
583     );
584
585     ok( ( none { $_->name eq 'KohaOpacLanguage' and $_->value eq 'tomas' } @$cookies ),
586         'BZ9735: invalid language, it is not set');
587
588     ok( ( any { $_->name eq 'KohaOpacLanguage' and $_->value eq 'en' } @$cookies ),
589         'BZ9735: invalid language, then default to en');
590
591     for my $template_name (
592         qw(
593             ../../../../../../../../../../../../../../../etc/passwd
594             test/../../../../../../../../../../../../../../etc/passwd
595             /etc/passwd
596             test/does_not_finished_by_tt_t
597         )
598     ) {
599         eval {
600             ( $template, $loggedinuser, $cookies ) = get_template_and_user(
601                 {
602                     template_name   => $template_name,
603                     query           => $query,
604                     type            => "intranet",
605                     authnotrequired => 1,
606                     flagsrequired   => { catalogue => 1 },
607                 }
608             );
609         };
610         like ( $@, qr(bad template path), "The file $template_name should not be accessible" );
611     }
612     ( $template, $loggedinuser, $cookies ) = get_template_and_user(
613         {
614             template_name   => 'errors/errorpage.tt',
615             query           => $query,
616             type            => "intranet",
617             authnotrequired => 1,
618             flagsrequired   => { catalogue => 1 },
619         }
620     );
621     my $file_exists = ( -f $template->{filename} ) ? 1 : 0;
622     is ( $file_exists, 1, 'The file errors/errorpage.tt should be accessible (contains integers)' );
623
624     # Regression test for env opac search limit override
625     $ENV{"OPAC_SEARCH_LIMIT"} = "branch:CPL";
626     $ENV{"OPAC_LIMIT_OVERRIDE"} = 1;
627
628     ( $template, $loggedinuser, $cookies) = get_template_and_user(
629         {
630             template_name => 'opac-main.tt',
631             query => $query,
632             type => 'opac',
633             authnotrequired => 1,
634         }
635     );
636     is($template->{VARS}->{'opac_name'}, "CPL", "Opac name was set correctly");
637     is($template->{VARS}->{'opac_search_limit'}, "branch:CPL", "Search limit was set correctly");
638
639     $ENV{"OPAC_SEARCH_LIMIT"} = "branch:multibranch-19";
640
641     ( $template, $loggedinuser, $cookies) = get_template_and_user(
642         {
643             template_name => 'opac-main.tt',
644             query => $query,
645             type => 'opac',
646             authnotrequired => 1,
647         }
648     );
649     is($template->{VARS}->{'opac_name'}, "multibranch-19", "Opac name was set correctly");
650     is($template->{VARS}->{'opac_search_limit'}, "branch:multibranch-19", "Search limit was set correctly");
651
652     delete $ENV{"HTTP_COOKIE"};
653 };
654
655 # Check that there is always an OPACBaseURL set.
656 my $input = CGI->new();
657 my ( $template1, $borrowernumber, $cookie );
658 ( $template1, $borrowernumber, $cookie ) = get_template_and_user(
659     {
660         template_name => "opac-detail.tt",
661         type => "opac",
662         query => $input,
663         authnotrequired => 1,
664     }
665 );
666
667 ok( ( any { 'OPACBaseURL' eq $_ } keys %{$template1->{VARS}} ),
668     'OPACBaseURL is in OPAC template' );
669
670 my ( $template2 );
671 ( $template2, $borrowernumber, $cookie ) = get_template_and_user(
672     {
673         template_name => "catalogue/detail.tt",
674         type => "intranet",
675         query => $input,
676         authnotrequired => 1,
677     }
678 );
679
680 ok( ( any { 'OPACBaseURL' eq $_ } keys %{$template2->{VARS}} ),
681     'OPACBaseURL is in Staff template' );
682
683 my $hash1 = hash_password('password');
684 my $hash2 = hash_password('password');
685
686 ok(C4::Auth::checkpw_hash('password', $hash1), 'password validates with first hash');
687 ok(C4::Auth::checkpw_hash('password', $hash2), 'password validates with second hash');
688
689 subtest 'Check value of login_attempts in checkpw' => sub {
690     plan tests => 11;
691
692     t::lib::Mocks::mock_preference('FailedLoginAttempts', 3);
693
694     # Only interested here in regular login
695     $C4::Auth::cas  = 0;
696     $C4::Auth::ldap = 0;
697
698     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
699     $patron->login_attempts(2);
700     $patron->password('123')->store; # yes, deliberately not hashed
701
702     is( $patron->account_locked, 0, 'Patron not locked' );
703     my @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
704         # Note: 123 will not be hashed to 123 !
705     is( $test[0], 0, 'checkpw should have failed' );
706     $patron->discard_changes; # refresh
707     is( $patron->login_attempts, 3, 'Login attempts increased' );
708     is( $patron->account_locked, 1, 'Check locked status' );
709
710     # And another try to go over the limit: different return value!
711     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
712     is( @test, 0, 'checkpw failed again and returns nothing now' );
713     $patron->discard_changes; # refresh
714     is( $patron->login_attempts, 3, 'Login attempts not increased anymore' );
715
716     # Administrative lockout cannot be undone?
717     # Pass the right password now (or: add a nice mock).
718     my $auth = Test::MockModule->new( 'C4::Auth' );
719     $auth->mock( 'checkpw_hash', sub { return 1; } ); # not for production :)
720     $patron->login_attempts(0)->store;
721     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
722     is( $test[0], 1, 'Build confidence in the mock' );
723     $patron->login_attempts(-1)->store;
724     is( $patron->account_locked, 1, 'Check administrative lockout' );
725     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
726     is( @test, 0, 'checkpw gave red' );
727     $patron->discard_changes; # refresh
728     is( $patron->login_attempts, -1, 'Still locked out' );
729     t::lib::Mocks::mock_preference('FailedLoginAttempts', ''); # disable
730     is( $patron->account_locked, 1, 'Check administrative lockout without pref' );
731 };
732
733 subtest 'Check value of login_attempts in checkpw' => sub {
734     plan tests => 2;
735
736     t::lib::Mocks::mock_preference('FailedLoginAttempts', 3);
737     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
738     $patron->set_password({ password => '123', skip_validation => 1 });
739
740     my @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
741     is( $test[0], 1, 'Patron authenticated correctly' );
742
743     $patron->password_expiration_date('2020-01-01')->store;
744     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
745     is( $test[0], -2, 'Patron returned as expired correctly' );
746
747 };
748
749 subtest '_timeout_syspref' => sub {
750
751     plan tests => 6;
752
753     t::lib::Mocks::mock_preference('timeout', "100");
754     is( C4::Auth::_timeout_syspref, 100, );
755
756     t::lib::Mocks::mock_preference('timeout', "2d");
757     is( C4::Auth::_timeout_syspref, 2*86400, );
758
759     t::lib::Mocks::mock_preference('timeout', "2D");
760     is( C4::Auth::_timeout_syspref, 2*86400, );
761
762     t::lib::Mocks::mock_preference('timeout', "10h");
763     is( C4::Auth::_timeout_syspref, 10*3600, );
764
765     t::lib::Mocks::mock_preference('timeout', "10x");
766     warning_is
767         { is( C4::Auth::_timeout_syspref, 600, ); }
768         "The value of the system preference 'timeout' is not correct, defaulting to 600",
769         'Bad values throw a warning and fallback to 600';
770 };
771
772 subtest 'check_cookie_auth' => sub {
773     plan tests => 4;
774
775     t::lib::Mocks::mock_preference('timeout', "1d"); # back to default
776
777     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 1 } });
778
779     # Mock a CGI object with real userid param
780     my $cgi = Test::MockObject->new();
781     $cgi->mock(
782         'param',
783         sub {
784             my $var = shift;
785             if ( $var eq 'userid' ) { return $patron->userid; }
786         }
787     );
788     $cgi->mock('multi_param', sub {return q{}} );
789     $cgi->mock( 'cookie', sub { return; } );
790     $cgi->mock( 'request_method', sub { return 'POST' } );
791
792     $ENV{REMOTE_ADDR} = '127.0.0.1';
793
794     # Setting authnotrequired=1 or we wont' hit the return but the end of the sub that prints headers
795     my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 1 );
796
797     my ($auth_status, $session) = C4::Auth::check_cookie_auth($sessionID);
798     isnt( $auth_status, 'ok', 'check_cookie_auth should not return ok if the user has not been authenticated before if no permissions needed' );
799     is( $auth_status, 'anon', 'check_cookie_auth should return anon if the user has not been authenticated before and no permissions needed' );
800
801     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 1 );
802
803     ($auth_status, $session) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
804     isnt( $auth_status, 'ok', 'check_cookie_auth should not return ok if the user has not been authenticated before and permissions needed' );
805     is( $auth_status, 'anon', 'check_cookie_auth should return anon if the user has not been authenticated before and permissions needed' );
806
807     #FIXME We should have a test to cover 'failed' status when a user has logged in, but doesn't have permission
808 };
809
810 subtest 'checkauth & check_cookie_auth' => sub {
811     plan tests => 34;
812
813     # flags = 4 => { catalogue => 1 }
814     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 4 } });
815     my $password = set_weak_password($patron);
816
817     my $cgi_mock = Test::MockModule->new('CGI');
818     $cgi_mock->mock( 'request_method', sub { return 'POST' } );
819
820     my $cgi = CGI->new;
821
822     my $auth = Test::MockModule->new( 'C4::Auth' );
823     # Tests will fail if we hit safe_exit
824     $auth->mock( 'safe_exit', sub { return } );
825
826     my ( $userid, $cookie, $sessionID, $flags );
827     {
828         # checkauth will redirect and safe_exit if not authenticated and not authorized
829         local *STDOUT;
830         my $stdout;
831         open STDOUT, '>', \$stdout;
832         C4::Auth::checkauth($cgi, 0, {catalogue => 1});
833         like( $stdout, qr{<title>\s*Log in to your account} );
834         $sessionID = ( $stdout =~ m{Set-Cookie: CGISESSID=((\d|\w)+);} ) ? $1 : undef;
835         ok($sessionID);
836         close STDOUT;
837     };
838
839     my $first_sessionID = $sessionID;
840
841     $ENV{"HTTP_COOKIE"} = "CGISESSID=$sessionID";
842     # Not authenticated yet, the login form is displayed
843     my $template;
844     ( $userid, $cookie, $sessionID, $flags, $template ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1}, 'intranet', undef, undef, { do_not_print => 1 } );
845     is( $template->{VARS}->{loginprompt}, 1, );
846
847     # Sending undefined fails obviously
848     my ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1} );
849     is( $auth_status, 'failed' );
850     is( $session, undef );
851
852     # Simulating the login form submission
853     $cgi->param('userid', $patron->userid);
854     $cgi->param('password', $password);
855
856     # Logged in!
857     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
858     is( $sessionID, $first_sessionID );
859     is( $userid, $patron->userid );
860
861     ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
862     is( $auth_status, 'ok' );
863     is( $session->id, $first_sessionID );
864
865     my $patron_to_delete = $builder->build_object({ class => 'Koha::Patrons' });
866     my $fresh_userid = $patron_to_delete->userid;
867     $patron_to_delete->delete;
868     my $old_userid   = $patron->userid;
869
870     # change the current session user's userid
871     $patron->userid( $fresh_userid )->store;
872     ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
873     is( $auth_status, 'expired' );
874     is( $session, undef );
875
876     # restore userid and generate a new session
877     $patron->userid($old_userid)->store;
878     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
879     is( $sessionID, $first_sessionID );
880     is( $userid, $patron->userid );
881
882     # Logging out!
883     $cgi->param('logout.x', 1);
884     $cgi->delete( 'userid', 'password' );
885     ( $userid, $cookie, $sessionID, $flags, $template ) =
886         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
887
888     is( $sessionID, undef );
889     is( $ENV{"HTTP_COOKIE"}, "CGISESSID=$first_sessionID", 'HTTP_COOKIE not unset' );
890     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, {catalogue => 1} );
891     is( $auth_status, "expired");
892     is( $session, undef );
893
894     {
895         # Trying to access without sessionID
896         $cgi = CGI->new;
897         ( $auth_status, $session) = C4::Auth::check_cookie_auth(undef, {catalogue => 1});
898         is( $auth_status, 'failed' );
899         is( $session, undef );
900
901         # This will fail on permissions
902         undef $ENV{"HTTP_COOKIE"};
903         {
904             local *STDOUT;
905             my $stdout;
906             open STDOUT, '>', \$stdout;
907             ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1} );
908             close STDOUT;
909         }
910         is( $userid, undef );
911         is( $sessionID, undef );
912     }
913
914     {
915         # First logging in
916         $cgi = CGI->new;
917         $cgi->param('userid', $patron->userid);
918         $cgi->param('password', $password);
919         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
920         is( $userid, $patron->userid );
921         $first_sessionID = $sessionID;
922
923         # Patron does not have the borrowers permission
924         # $ENV{"HTTP_COOKIE"} = "CGISESSID=$sessionID"; # not needed, we use $cgi here
925         {
926             local *STDOUT;
927             my $stdout;
928             open STDOUT, '>', \$stdout;
929             ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {borrowers => 1} );
930             close STDOUT;
931         }
932         is( $userid, undef );
933         is( $sessionID, undef );
934
935         # When calling check_cookie_auth, the session will be deleted
936         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, { borrowers => 1 } );
937         is( $auth_status, "failed" );
938         is( $session, undef );
939         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, { borrowers => 1 } );
940         is( $auth_status, 'expired', 'Session no longer exists' );
941
942         # NOTE: It is not what the UI is doing.
943         # From the UI we are allowed to hit an unauthorized page then reuse the session to hit back authorized area.
944         # It is because check_cookie_auth is ALWAYS called from checkauth WITHOUT $flagsrequired
945         # It then return "ok", when the previous called got "failed"
946
947         # Try reusing the deleted session: since it does not exist, we should get a new one now when passing correct permissions
948         $cgi->cookie( -name => 'CGISESSID', value => $first_sessionID );
949         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
950         is( $userid, $patron->userid );
951         isnt( $sessionID, undef, 'Check if we have a sessionID' );
952         isnt( $sessionID, $first_sessionID, 'New value expected' );
953         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID, {catalogue => 1} );
954         is( $auth_status, "ok" );
955         is( $session->id, $sessionID, 'Same session' );
956         # Two additional tests on userenv
957         is( $C4::Context::context->{activeuser}, $session->id, 'Check if environment has been setup for session' );
958         is( C4::Context->userenv->{id}, $userid, 'Check userid in userenv' );
959     }
960 };
961
962 subtest 'Userenv clearing in check_cookie_auth' => sub {
963     # Note: We did already test userenv for a logged-in user in previous subtest
964     plan tests => 9;
965
966     t::lib::Mocks::mock_preference( 'timeout', 600 );
967     my $cgi = CGI->new;
968
969     # Create a new anonymous session by passing a fake session ID
970     $cgi->cookie( -name => 'CGISESSID', -value => 'fake_sessionID' );
971     my ($userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 1);
972     my ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
973     is( $auth_status, 'anon', 'Should be anonymous' );
974     is( $C4::Context::context->{activeuser}, $session->id, 'Check activeuser' );
975     is( defined C4::Context->userenv, 1, 'There should be a userenv' );
976     is(  C4::Context->userenv->{id}, q{}, 'userid should be empty string' );
977
978     # Make the session expire now, check_cookie_auth will delete it
979     $session->param('lasttime', time() - 1200 );
980     $session->flush;
981     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
982     is( $auth_status, 'expired', 'Should be expired' );
983     is( C4::Context->userenv, undef, 'Environment should be cleared too' );
984
985     # Show that we clear the userenv again: set up env and check deleted session
986     C4::Context->_new_userenv( $sessionID );
987     C4::Context->set_userenv; # empty
988     is( defined C4::Context->userenv, 1, 'There should be an empty userenv again' );
989     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
990     is( $auth_status, 'expired', 'Should be expired already' );
991     is( C4::Context->userenv, undef, 'Environment should be cleared again' );
992 };
993
994 subtest 'create_basic_session tests' => sub {
995     plan tests => 13;
996
997     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
998
999     my $session = C4::Auth::create_basic_session({ patron => $patron, interface => 'opac' });
1000
1001     isnt($session->id, undef, 'A new sessionID was created');
1002     is( $session->param('number'), $patron->borrowernumber, 'Session parameter number matches' );
1003     is( $session->param('id'), $patron->userid, 'Session parameter id matches' );
1004     is( $session->param('cardnumber'), $patron->cardnumber, 'Session parameter cardnumber matches' );
1005     is( $session->param('firstname'), $patron->firstname, 'Session parameter firstname matches' );
1006     is( $session->param('surname'), $patron->surname, 'Session parameter surname matches' );
1007     is( $session->param('branch'), $patron->branchcode, 'Session parameter branch matches' );
1008     is( $session->param('branchname'), $patron->library->branchname, 'Session parameter branchname matches' );
1009     is( $session->param('flags'), $patron->flags, 'Session parameter flags matches' );
1010     is( $session->param('emailaddress'), $patron->email, 'Session parameter emailaddress matches' );
1011     is( $session->param('ip'), $session->remote_addr(), 'Session parameter ip matches' );
1012     is( $session->param('interface'), 'opac', 'Session parameter interface matches' );
1013
1014     $session = C4::Auth::create_basic_session({ patron => $patron, interface => 'staff' });
1015     is( $session->param('interface'), 'intranet', 'Staff interface gets converted to intranet' );
1016 };
1017
1018 subtest 'check_cookie_auth overwriting interface already set' => sub {
1019     plan tests => 2;
1020
1021     t::lib::Mocks::mock_preference( 'SessionRestrictionByIP', 0 );
1022
1023     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
1024     my $session = C4::Auth::get_session();
1025     $session->param( 'number',       $patron->id );
1026     $session->param( 'id',           $patron->userid );
1027     $session->param( 'ip',           '1.2.3.4' );
1028     $session->param( 'lasttime',     time() );
1029     $session->param( 'interface',    'opac' );
1030     $session->flush;
1031
1032     C4::Context->interface('intranet');
1033     C4::Auth::check_cookie_auth( $session->id );
1034     is( C4::Context->interface, 'intranet', 'check_cookie_auth did not overwrite' );
1035     delete $C4::Context::context->{interface}; # clear context interface
1036     C4::Auth::check_cookie_auth( $session->id );
1037     is( C4::Context->interface, 'opac', 'check_cookie_auth used interface from session when context interface was empty' );
1038
1039     t::lib::Mocks::mock_preference( 'SessionRestrictionByIP', 1 );
1040 };
1041
1042 $schema->storage->txn_rollback;
1043
1044 subtest 'get_cataloguing_page_permissions() tests' => sub {
1045
1046     plan tests => 6;
1047
1048     $schema->storage->txn_begin;
1049
1050     my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { flags => 2**2 } } );    # catalogue
1051
1052     ok(
1053         !C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1054         '"catalogue" is not enough to see the cataloguing page'
1055     );
1056
1057     $builder->build(
1058         {
1059             source => 'UserPermission',
1060             value  => {
1061                 borrowernumber => $patron->id,
1062                 module_bit     => 24,               # stockrotation
1063                 code           => 'manage_rotas',
1064             },
1065         }
1066     );
1067
1068     t::lib::Mocks::mock_preference( 'StockRotation', 1 );
1069     ok(
1070         C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1071         '"stockrotation => manage_rotas" is enough'
1072     );
1073
1074     t::lib::Mocks::mock_preference( 'StockRotation', 0 );
1075     ok(
1076         !C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1077         '"stockrotation => manage_rotas" is not enough when `StockRotation` is disabled'
1078     );
1079
1080     $builder->build(
1081         {
1082             source => 'UserPermission',
1083             value  => {
1084                 borrowernumber => $patron->id,
1085                 module_bit     => 13,                     # tools
1086                 code           => 'manage_staged_marc',
1087             },
1088         }
1089     );
1090
1091     ok(
1092         C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1093         'Having one of the listed `tools` subperm is enough'
1094     );
1095
1096     $schema->resultset('UserPermission')->search( { borrowernumber => $patron->id } )->delete;
1097
1098     ok(
1099         !C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1100         'Permission removed, no access'
1101     );
1102
1103     $builder->build(
1104         {
1105             source => 'UserPermission',
1106             value  => {
1107                 borrowernumber => $patron->id,
1108                 module_bit     => 9,                    # editcatalogue
1109                 code           => 'delete_all_items',
1110             },
1111         }
1112     );
1113
1114     ok(
1115         C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1116         'Having any `editcatalogue` subperm is enough'
1117     );
1118
1119     $schema->storage->txn_rollback;
1120 };
1121
1122 subtest 'checkpw() return values tests' => sub {
1123
1124     plan tests => 3;
1125
1126     subtest 'Internal check tests' => sub {
1127
1128         plan tests => 25;
1129
1130         $schema->storage->txn_begin;
1131
1132         my $account_locked;
1133         my $password_expired;
1134
1135         my $mock_patron = Test::MockModule->new('Koha::Patron');
1136         $mock_patron->mock( 'account_locked',   sub { return $account_locked; } );
1137         $mock_patron->mock( 'password_expired', sub { return $password_expired; } );
1138
1139         # Only interested here in regular login
1140         t::lib::Mocks::mock_config( 'useshibboleth', undef );
1141         $C4::Auth::cas  = 0;
1142         $C4::Auth::ldap = 0;
1143
1144         my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
1145         my $password = set_weak_password($patron);
1146
1147         my $patron_to_delete  = $builder->build_object( { class => 'Koha::Patrons' } );
1148         my $unused_userid     = $patron_to_delete->userid;
1149         my $unused_cardnumber = $patron_to_delete->cardnumber;
1150         $patron_to_delete->delete;
1151
1152         $account_locked = 1;
1153         my @return = checkpw( $patron->userid, $password, undef, );
1154         is_deeply( \@return, [], 'If the account is locked, empty list is returned' );
1155
1156         $account_locked = 0;
1157
1158         my @matchpoints = qw(userid cardnumber);
1159         foreach my $matchpoint (@matchpoints) {
1160
1161             @return = checkpw( $patron->$matchpoint, $password, undef, );
1162
1163             is( $return[0],        1,                   "Password validation successful returns 1 ($matchpoint)" );
1164             is( $return[1],        $patron->cardnumber, '`cardnumber` returned' );
1165             is( $return[2],        $patron->userid,     '`userid` returned' );
1166             is( ref( $return[3] ), 'Koha::Patron',      'Koha::Patron object reference returned' );
1167             is( $return[3]->id,    $patron->id,         'Correct patron returned' );
1168         }
1169
1170         @return = checkpw( $patron->userid, $password . 'hey', undef, );
1171
1172         is( scalar @return,    2, "Two results on invalid password scenario" );
1173         is( $return[0],        0, '0 returned on invalid password' );
1174         is( ref( $return[1] ), 'Koha::Patron' );
1175         is( $return[1]->id,    $patron->id, 'Patron matched correctly' );
1176
1177         $password_expired = 1;
1178         @return           = checkpw( $patron->userid, $password, undef, );
1179
1180         is( scalar @return,    2,  "Two results on expired password scenario" );
1181         is( $return[0],        -2, '-2 returned' );
1182         is( ref( $return[1] ), 'Koha::Patron' );
1183         is( $return[1]->id,    $patron->id, 'Patron matched correctly' );
1184
1185         @return = checkpw( $unused_userid, $password, undef, );
1186
1187         is( scalar @return, 2,     "Two results on non-existing userid scenario" );
1188         is( $return[0],     0,     '0 returned' );
1189         is( $return[1],     undef, 'Undef returned, representing no match' );
1190
1191         @return = checkpw( $unused_cardnumber, $password, undef, );
1192
1193         is( scalar @return, 2,     "Only one result on non-existing cardnumber scenario" );
1194         is( $return[0],     0,     '0 returned' );
1195         is( $return[1],     undef, 'Undef returned, representing no match' );
1196
1197         $schema->storage->txn_rollback;
1198     };
1199
1200     subtest 'CAS check (mocked) tests' => sub {
1201
1202         plan tests => 25;
1203
1204         $schema->storage->txn_begin;
1205
1206         my $account_locked;
1207         my $password_expired;
1208
1209         my $mock_patron = Test::MockModule->new('Koha::Patron');
1210         $mock_patron->mock( 'account_locked',   sub { return $account_locked; } );
1211         $mock_patron->mock( 'password_expired', sub { return $password_expired; } );
1212
1213         # Only interested here in regular login
1214         t::lib::Mocks::mock_config( 'useshibboleth', undef );
1215         $C4::Auth::cas  = 1;
1216         $C4::Auth::ldap = 0;
1217
1218         my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
1219         my $password = 'thePassword123';
1220         $patron->set_password( { password => $password, skip_validation => 1 } );
1221
1222         my $patron_to_delete  = $builder->build_object( { class => 'Koha::Patrons' } );
1223         my $unused_userid     = $patron_to_delete->userid;
1224         my $unused_cardnumber = $patron_to_delete->cardnumber;
1225         $patron_to_delete->delete;
1226
1227         my $ticket = '123456';
1228         my $query  = CGI->new;
1229         $query->param( -name => 'ticket', -value => $ticket );
1230
1231         my @cas_return = ( 1, $patron->cardnumber, $patron->userid, $ticket, Koha::Patrons->find( $patron->id ) );
1232
1233         my $cas_mock = Test::MockModule->new('C4::Auth');
1234         $cas_mock->mock( 'checkpw_cas', sub { return @cas_return; } );
1235
1236         $account_locked = 1;
1237         my @return = checkpw( $patron->userid, $password, $query, );
1238         is_deeply( \@return, [], 'If the account is locked, empty list is returned' );
1239
1240         $account_locked = 0;
1241
1242         my @matchpoints = qw(userid cardnumber);
1243         foreach my $matchpoint (@matchpoints) {
1244
1245             @return = checkpw( $patron->$matchpoint, $password, $query, );
1246
1247             is( $return[0],        1,                   "Password validation successful returns 1 ($matchpoint)" );
1248             is( $return[1],        $patron->cardnumber, '`cardnumber` returned' );
1249             is( $return[2],        $patron->userid,     '`userid` returned' );
1250             is( ref( $return[3] ), 'Koha::Patron',      'Koha::Patron object reference returned' );
1251             is( $return[3]->id,    $patron->id,         'Correct patron returned' );
1252         }
1253
1254         @return = checkpw( $patron->userid, $password . 'hey', $query, );
1255
1256         is( scalar @return,    2, "Two results on invalid password scenario" );
1257         is( $return[0],        0, '0 returned on invalid password' );
1258         is( ref( $return[1] ), 'Koha::Patron' );
1259         is( $return[1]->id,    $patron->id, 'Patron matched correctly' );
1260
1261         $password_expired = 1;
1262         @return           = checkpw( $patron->userid, $password, $query, );
1263
1264         is( scalar @return,    2,  "Two results on expired password scenario" );
1265         is( $return[0],        -2, '-2 returned' );
1266         is( ref( $return[1] ), 'Koha::Patron' );
1267         is( $return[1]->id,    $patron->id, 'Patron matched correctly' );
1268
1269         @return = checkpw( $unused_userid, $password, $query, );
1270
1271         is( scalar @return, 2,     "Two results on non-existing userid scenario" );
1272         is( $return[0],     0,     '0 returned' );
1273         is( $return[1],     undef, 'Undef returned, representing no match' );
1274
1275         @return = checkpw( $unused_cardnumber, $password, $query, );
1276
1277         is( scalar @return, 2,     "Only one result on non-existing cardnumber scenario" );
1278         is( $return[0],     0,     '0 returned' );
1279         is( $return[1],     undef, 'Undef returned, representing no match' );
1280
1281         $schema->storage->txn_rollback;
1282     };
1283
1284     subtest 'Shibboleth check (mocked) tests' => sub {
1285
1286         plan tests => 6;
1287
1288         $schema->storage->txn_begin;
1289
1290         my $account_locked;
1291         my $password_expired;
1292
1293         my $mock_patron = Test::MockModule->new('Koha::Patron');
1294         $mock_patron->mock( 'account_locked',   sub { return $account_locked; } );
1295         $mock_patron->mock( 'password_expired', sub { return $password_expired; } );
1296
1297         # Only interested here in regular login
1298         t::lib::Mocks::mock_config( 'useshibboleth', 1 );
1299         $C4::Auth::cas  = 0;
1300         $C4::Auth::ldap = 0;
1301
1302         my $patron   = $builder->build_object( { class => 'Koha::Patrons' } );
1303         my $password = 'thePassword123';
1304         $patron->set_password( { password => $password, skip_validation => 1 } );
1305
1306         my $patron_to_delete  = $builder->build_object( { class => 'Koha::Patrons' } );
1307         my $unused_userid     = $patron_to_delete->userid;
1308         my $unused_cardnumber = $patron_to_delete->cardnumber;
1309         $patron_to_delete->delete;
1310
1311         my @shib_return = ( 1, $patron->cardnumber, $patron->userid, Koha::Patrons->find( $patron->id ) );
1312
1313         my $auth_mock = Test::MockModule->new('C4::Auth');
1314         $auth_mock->mock( 'shib_ok',        1 );
1315         $auth_mock->mock( 'get_login_shib', 1 );
1316
1317         my $shib_mock = Test::MockModule->new('C4::Auth_with_shibboleth');
1318         $shib_mock->mock( 'checkpw_shib', sub { return @shib_return; } );
1319
1320         $account_locked = 1;
1321         my @return = checkpw( $patron->userid );
1322         is_deeply( \@return, [], 'If the account is locked, empty list is returned' );
1323
1324         $account_locked = 0;
1325
1326         @return = checkpw();
1327
1328         is( $return[0],        1,                   "Password validation successful returns 1" );
1329         is( $return[1],        $patron->cardnumber, '`cardnumber` returned' );
1330         is( $return[2],        $patron->userid,     '`userid` returned' );
1331         is( ref( $return[3] ), 'Koha::Patron',      'Koha::Patron object reference returned' );
1332         is( $return[3]->id,    $patron->id,         'Correct patron returned' );
1333
1334         $schema->storage->txn_rollback;
1335     };
1336 };
1337
1338 subtest 'StaffLoginBranchBasedOnIP' => sub {
1339
1340     plan tests => 7;
1341
1342     $schema->storage->txn_begin;
1343
1344     t::lib::Mocks::mock_preference( 'AutoLocation',              0 );
1345     t::lib::Mocks::mock_preference( 'StaffLoginBranchBasedOnIP', 0 );
1346
1347     my $patron   = $builder->build_object( { class => 'Koha::Patrons',   value => { flags    => 1 } } );
1348     my $branch   = $builder->build_object( { class => 'Koha::Libraries', value => { branchip => "127.0.0.1" } } );
1349     my $password = 'password';
1350     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
1351     $patron->set_password( { password => $password } );
1352
1353     my $cgi_mock = Test::MockModule->new('CGI');
1354     $cgi_mock->mock( 'request_method', sub { return 'POST' } );
1355     my $cgi  = CGI->new;
1356     my $auth = Test::MockModule->new('C4::Auth');
1357
1358     # Simulating the login form submission
1359     $cgi->param( 'userid',   $patron->userid );
1360     $cgi->param( 'password', $password );
1361
1362     $ENV{REMOTE_ADDR} = '127.0.0.1';
1363     my ( $userid, $cookie, $sessionID, $flags ) =
1364         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1365     is( $userid, $patron->userid, "User successfully logged in" );
1366     my $session = C4::Auth::get_session($sessionID);
1367     is( $session->param('branch'), $patron->branchcode, "Logged in branch is set to the patron's branchcode" );
1368
1369     my $template;
1370     t::lib::Mocks::mock_preference( 'StaffLoginBranchBasedOnIP', 1 );
1371
1372     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1373     is( $userid, $patron->userid, "User successfully logged in" );
1374     $session = C4::Auth::get_session($sessionID);
1375     is( $session->param('branch'), $branch->branchcode, "Logged in branch is set based on the IP from REMOTE_ADDR " );
1376
1377     # AutoLocation overrides StaffLoginBranchBasedOnIP
1378     t::lib::Mocks::mock_preference( 'AutoLocation', 1 );
1379     ( $userid, $cookie, $sessionID, $flags, $template ) =
1380         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
1381     is(
1382         $template->{VARS}->{wrongip}, 1,
1383         "AutoLocation prevents StaffLoginBranchBasedOnIP from logging user in to another branch"
1384     );
1385
1386     t::lib::Mocks::mock_preference( 'AutoLocation', 0 );
1387     my $other_branch = $builder->build_object(
1388         {
1389             class => 'Koha::Libraries',
1390             value => { branchip => "127.0.0.1", branchcode => substr( "z" . $branch->branchcode, 0, 10 ) }
1391         }
1392     );
1393     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1394     is( $userid, $patron->userid, "User successfully logged in" );
1395     $session = C4::Auth::get_session($sessionID);
1396     is(
1397         $session->param('branch'), $branch->branchcode,
1398         "Logged in branch is set based which branch when two libraries have same IP?"
1399     );
1400
1401 };
1402
1403 subtest 'AutoLocation' => sub {
1404
1405     plan tests => 12;
1406
1407     $schema->storage->txn_begin;
1408
1409     t::lib::Mocks::mock_preference( 'AutoLocation', 0 );
1410
1411     my $patron   = $builder->build_object( { class => 'Koha::Patrons', value => { flags => 1 } } );
1412     my $password = 'password';
1413     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
1414     $patron->set_password( { password => $password } );
1415
1416     my $cgi_mock = Test::MockModule->new('CGI');
1417     $cgi_mock->mock( 'request_method', sub { return 'POST' } );
1418     my $cgi  = CGI->new;
1419     my $auth = Test::MockModule->new('C4::Auth');
1420
1421     # Simulating the login form submission
1422     $cgi->param( 'userid',   $patron->userid );
1423     $cgi->param( 'password', $password );
1424
1425     $ENV{REMOTE_ADDR} = '127.0.0.1';
1426     my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1427     is( $userid, $patron->userid, "Standard login without AutoLocation" );
1428
1429     my $template;
1430     t::lib::Mocks::mock_preference( 'AutoLocation', 1 );
1431
1432     # AutoLocation: "Require staff to log in from a computer in the IP address range specified by their library (if any)"
1433     $patron->library->branchip('')->store;    # There is none, allow access from anywhere
1434     ( $userid, $cookie, $sessionID, $flags, $template ) =
1435         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1436     is( $userid,   $patron->userid, "Login is successful when patron's branch does not have an IP" );
1437     is( $template, undef,           "Template is undef as none passed and not sent to error page" );
1438
1439     $patron->library->branchip('1.2.3.4')->store;
1440     ( $userid, $cookie, $sessionID, $flags, $template ) =
1441         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
1442     is(
1443         $template->{VARS}->{wrongip}, 1,
1444         "Login denied when no branch specified and IP does not match patron's branch IP"
1445     );
1446
1447     $patron->library->branchip('127.0.0.1')->store;
1448     ( $userid, $cookie, $sessionID, $flags, $template ) =
1449         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1450     is( $userid,   $patron->userid, "Login is successful when patron IP and branch IP match" );
1451     is( $template, undef,           "Template is undef as none passed and not sent to error page" );
1452
1453     my $other_library = $builder->build_object( { class => 'Koha::Libraries', value => { branchip => '127.0.0.1' } } );
1454     $patron->library->branchip('127.0.0.1')->store;
1455     ( $userid, $cookie, $sessionID, $flags, $template ) =
1456         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1457     my $session = C4::Auth::get_session($sessionID);
1458     is(
1459         $session->param('branch'), $patron->branchcode,
1460         "If no branch specified, and IP matches patron branch, login is successful at patron branch even if another branch IP matches"
1461     );
1462
1463     $cgi->param( 'branch', $other_library->branchcode );
1464     ( $userid, $cookie, $sessionID, $flags, $template ) =
1465         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
1466     $session = C4::Auth::get_session($sessionID);
1467     is(
1468         $session->param('branch'), $other_library->branchcode,
1469         "AutoLocation allows specifying a branch as long as the IP matches"
1470     );
1471
1472     $other_library->branchip('129.0.0.1')->store;
1473     ( $userid, $cookie, $sessionID, $flags, $template ) =
1474         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
1475     is( $template->{VARS}->{wrongip}, 1, "Login denied when branch specified and IP does not match branch IP" );
1476
1477     my $noip_library = $builder->build_object( { class => 'Koha::Libraries', value => { branchip => '' } } );
1478     $cgi->param( 'branch', $noip_library->branchcode );
1479     ( $userid, $cookie, $sessionID, $flags, $template ) =
1480         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' );
1481     $session = C4::Auth::get_session($sessionID);
1482     is(
1483         $session->param('branch'), $noip_library->branchcode,
1484         "When a branch with no IP set is chosen, we respect the choice regardless of current IP"
1485     );
1486
1487     $ENV{REMOTE_ADDR} = '129.0.0.1';          # Set current IP to match other_branch
1488     $cgi->param( 'branch', '' );              # Do not pass a branch
1489     $patron->library->branchip('')->store;    # Unset user branch IP, to allow IP matching on any branch
1490                                               # Add a second branch with same IP
1491     my $another_library = $builder->build_object(
1492         {
1493             class => 'Koha::Libraries',
1494             value => { branchip => "129.0.0.1", branchcode => substr( "z" . $other_library->branchcode, 0, 10 ) }
1495         }
1496     );
1497     ( $userid, $cookie, $sessionID, $flags, $template ) =
1498         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
1499     $session = C4::Auth::get_session($sessionID);
1500     is(
1501         $session->param('branch'), $patron->library->branchcode,
1502         "When user branch has no IP, and no branch chosen, user is logged in to their homebranch"
1503     );
1504
1505     $cgi->param( 'branch', $another_library->branchcode )
1506         ;    # Choose branch with duplicate IP and alphabetically later branchcode
1507     ( $userid, $cookie, $sessionID, $flags, $template ) =
1508         C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet', undef, undef, { do_not_print => 1 } );
1509     $session = C4::Auth::get_session($sessionID);
1510     is(
1511         $session->param('branch'), $another_library->branchcode,
1512         "When there is an IP conflict, we use the chosen branch if it matches"
1513     );
1514
1515     $schema->storage->txn_rollback;
1516
1517 };
1518
1519 sub set_weak_password {
1520     my ($patron) = @_;
1521     my $password = 'password';
1522     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
1523     $patron->set_password( { password => $password } );
1524     return $password;
1525 }