Bug 34912: Add 14 hours to 1970 date in Account(s).t
[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 => 19;
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::Database;
19 use Koha::Patrons;
20 use Koha::Auth::TwoFactorAuth;
21
22 BEGIN {
23     use_ok(
24         'C4::Auth',
25         qw( checkauth haspermission track_login_daily checkpw get_template_and_user checkpw_hash get_cataloguing_page_permissions )
26     );
27 }
28
29 my $schema  = Koha::Database->schema;
30 my $builder = t::lib::TestBuilder->new;
31
32 # FIXME: SessionStorage defaults to mysql, but it seems to break transaction
33 # handling
34 t::lib::Mocks::mock_preference( 'SessionStorage', 'tmp' );
35 t::lib::Mocks::mock_preference( 'PrivacyPolicyConsent', '' ); # Disabled
36
37 # To silence useless warnings
38 $ENV{REMOTE_ADDR} = '127.0.0.1';
39
40 $schema->storage->txn_begin;
41
42 subtest 'checkauth() tests' => sub {
43
44     plan tests => 9;
45
46     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => undef } });
47
48     # Mock a CGI object with real userid param
49     my $cgi = Test::MockObject->new();
50     $cgi->mock(
51         'param',
52         sub {
53             my $var = shift;
54             if ( $var eq 'userid' ) { return $patron->userid; }
55         }
56     );
57     $cgi->mock( 'cookie', sub { return; } );
58     $cgi->mock( 'request_method', sub { return 'POST' } );
59
60     my $authnotrequired = 1;
61     my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, $authnotrequired );
62
63     is( $userid, undef, 'checkauth() returns undef for userid if no logged in user (Bug 18275)' );
64
65     my $db_user_id = C4::Context->config('user');
66     my $db_user_pass = C4::Context->config('pass');
67     $cgi = Test::MockObject->new();
68     $cgi->mock( 'cookie', sub { return; } );
69     $cgi->mock( 'param', sub {
70             my ( $self, $param ) = @_;
71             if ( $param eq 'userid' ) { return $db_user_id; }
72             elsif ( $param eq 'password' ) { return $db_user_pass; }
73             else { return; }
74         });
75     $cgi->mock( 'request_method', sub { return 'POST' } );
76     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, $authnotrequired );
77     is ( $userid, undef, 'If DB user is used, it should not be logged in' );
78
79     my $is_allowed = C4::Auth::haspermission( $db_user_id, { can_do => 'everything' } );
80
81     # FIXME This belongs to t/db_dependent/Auth/haspermission.t but we do not want to c/p the pervious mock statements
82     ok( !$is_allowed, 'DB user should not have any permissions');
83
84     subtest 'Prevent authentication when sending credential via GET' => sub {
85
86         plan tests => 2;
87
88         my $patron = $builder->build_object(
89             { class => 'Koha::Patrons', value => { flags => 1 } } );
90         my $password = 'password';
91         t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
92         $patron->set_password( { password => $password } );
93         $cgi = Test::MockObject->new();
94         $cgi->mock( 'cookie', sub { return; } );
95         $cgi->mock(
96             'param',
97             sub {
98                 my ( $self, $param ) = @_;
99                 if    ( $param eq 'userid' )   { return $patron->userid; }
100                 elsif ( $param eq 'password' ) { return $password; }
101                 else                           { return; }
102             }
103         );
104
105         $cgi->mock( 'request_method', sub { return 'POST' } );
106         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
107         is( $userid, $patron->userid, 'If librarian user is used and password with POST, they should be logged in' );
108
109         $cgi->mock( 'request_method', sub { return 'GET' } );
110         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
111         is( $userid, undef, 'If librarian user is used and password with GET, they should not be logged in' );
112     };
113
114     subtest 'Template params tests (password_expired)' => sub {
115
116         plan tests => 1;
117
118         my $password_expired;
119
120         my $patron_class = Test::MockModule->new('Koha::Patron');
121         $patron_class->mock( 'password_expired', sub { return $password_expired; } );
122
123         my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 1 } });
124         my $password = 'password';
125         t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
126         $patron->set_password( { password => $password } );
127
128         my $cgi_mock = Test::MockModule->new('CGI')->mock( 'request_method', 'POST' );
129         my $cgi = CGI->new;
130         $cgi->param( -name => 'userid',   -value => $patron->userid );
131         $cgi->param( -name => 'password', -value => $password );
132
133         my $auth = Test::MockModule->new( 'C4::Auth' );
134         # Tests will fail if we hit safe_exit
135         $auth->mock( 'safe_exit', sub { return } );
136
137         my ( $userid, $cookie, $sessionID, $flags );
138
139         {
140             t::lib::Mocks::mock_preference( 'DumpTemplateVarsOpac', 1 );
141             # checkauth will redirect and safe_exit if not authenticated and not authorized
142             local *STDOUT;
143             my $stdout;
144             open STDOUT, '>', \$stdout;
145
146             # Password has expired
147             $password_expired = 1;
148             C4::Auth::checkauth( $cgi, 0, { catalogue => 1 } );
149             like( $stdout, qr{'password_has_expired' => 1}, 'password_has_expired is set to 1' );
150
151             close STDOUT;
152         };
153     };
154
155     subtest 'Reset auth state when changing users' => sub {
156
157         #NOTE: It's easiest to detect this when changing to a non-existent user, since
158         #that should trigger a redirect to login (instead of returning a session cookie)
159         plan tests => 2;
160         my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { flags => undef } } );
161
162         my $session = C4::Auth::get_session();
163         $session->param( 'number',    $patron->id );
164         $session->param( 'id',        $patron->userid );
165         $session->param( 'ip',        '1.2.3.4' );
166         $session->param( 'lasttime',  time() );
167         $session->param( 'interface', 'intranet' );
168         $session->flush;
169         my $sessionID = $session->id;
170         C4::Context->_new_userenv($sessionID);
171
172         my ($return) =
173             C4::Auth::check_cookie_auth( $sessionID, undef, { skip_version_check => 1, remote_addr => '1.2.3.4' } );
174         is( $return, 'ok', 'Patron authenticated' );
175
176         my $mock1 = Test::MockModule->new('C4::Auth');
177         $mock1->mock( 'safe_exit', sub { return 'safe_exit_redirect' } );
178         my $mock2 = Test::MockModule->new('CGI');
179         $mock2->mock( 'request_method', 'POST' );
180         $mock2->mock( 'cookie',         sub { return $sessionID; } );    # oversimplified..
181         my $cgi = CGI->new;
182
183         $cgi->param( -name => 'userid',             -value => 'Bond' );
184         $cgi->param( -name => 'password',           -value => 'James Bond' );
185         $cgi->param( -name => 'koha_login_context', -value => 1 );
186         my ( @return, $stdout );
187         {
188             local *STDOUT;
189             local %ENV;
190             $ENV{REMOTE_ADDR} = '1.2.3.4';
191             open STDOUT, '>', \$stdout;
192             @return = C4::Auth::checkauth( $cgi, 0, {} );
193             close STDOUT;
194         }
195         is( $return[0], 'safe_exit_redirect', 'Changing to non-existent user causes a redirect to login' );
196     };
197
198
199     subtest 'While still logged in, relogin with another user' => sub {
200         plan tests => 6;
201
202         my $patron = $builder->build_object({ class => 'Koha::Patrons', value => {} });
203         my $patron2 = $builder->build_object({ class => 'Koha::Patrons', value => {} });
204         # Create 'former' session
205         my $session = C4::Auth::get_session();
206         $session->param( 'number',       $patron->id );
207         $session->param( 'id',           $patron->userid );
208         $session->param( 'ip',           '1.2.3.4' );
209         $session->param( 'lasttime',     time() );
210         $session->param( 'interface',    'opac' );
211         $session->flush;
212         my $sessionID = $session->id;
213         C4::Context->_new_userenv($sessionID);
214
215         my ( $return ) = C4::Auth::check_cookie_auth( $sessionID, undef, { skip_version_check => 1, remote_addr => '1.2.3.4' } );
216         is( $return, 'ok', 'Former session in shape now' );
217
218         my $mock1 = Test::MockModule->new('C4::Auth');
219         $mock1->mock( 'safe_exit', sub {} );
220         my $mock2 = Test::MockModule->new('CGI');
221         $mock2->mock( 'request_method', 'POST' );
222         $mock2->mock( 'cookie', sub { return $sessionID; } ); # oversimplified..
223         my $cgi = CGI->new;
224         my $password = 'Incr3d1blyZtr@ng93$';
225         $patron2->set_password({ password => $password });
226         $cgi->param( -name => 'userid',             -value => $patron2->userid );
227         $cgi->param( -name => 'password',           -value => $password );
228         $cgi->param( -name => 'koha_login_context', -value => 1 );
229         my ( @return, $stdout );
230         {
231             local *STDOUT;
232             local %ENV;
233             $ENV{REMOTE_ADDR} = '1.2.3.4';
234             open STDOUT, '>', \$stdout;
235             @return = C4::Auth::checkauth( $cgi, 0, {} );
236             close STDOUT;
237         }
238         # Note: We can test return values from checkauth here since we mocked the safe_exit after the Redirect 303
239         is( $return[0], $patron2->userid, 'Login of patron2 approved' );
240         isnt( $return[2], $sessionID, 'Did not return previous session ID' );
241         ok( $return[2], 'New session ID not empty' );
242
243         # Similar situation: Relogin with former session of $patron, new user $patron2 has no permissions
244         $patron2->flags(undef)->store;
245         $session->param( 'number',       $patron->id );
246         $session->param( 'id',           $patron->userid );
247         $session->param( 'interface',    'intranet' );
248         $session->flush;
249         $sessionID = $session->id;
250         C4::Context->_new_userenv($sessionID);
251         $cgi->param( -name => 'userid',             -value => $patron2->userid );
252         $cgi->param( -name => 'password',           -value => $password );
253         $cgi->param( -name => 'koha_login_context', -value => 1 );
254         {
255             local *STDOUT;
256             local %ENV;
257             $ENV{REMOTE_ADDR} = '1.2.3.4';
258             $stdout = q{};
259             open STDOUT, '>', \$stdout;
260             @return = C4::Auth::checkauth( $cgi, 0, { catalogue => 1 }, 'intranet' ); # patron2 has no catalogue perm
261             close STDOUT;
262         }
263         like( $stdout, qr/You do not have permission to access this page/, 'No permission response' );
264         is( @return, 0, 'checkauth returned failure' );
265     };
266
267     subtest 'Two-factor authentication' => sub {
268         plan tests => 18;
269
270         my $patron = $builder->build_object(
271             { class => 'Koha::Patrons', value => { flags => 1 } } );
272         my $password = 'password';
273         $patron->set_password( { password => $password } );
274         $cgi = Test::MockObject->new();
275
276         my $otp_token;
277         our ( $logout, $sessionID, $verified );
278         $cgi->mock(
279             'param',
280             sub {
281                 my ( $self, $param ) = @_;
282                 if    ( $param eq 'userid' )    { return $patron->userid; }
283                 elsif ( $param eq 'password' )  { return $password; }
284                 elsif ( $param eq 'otp_token' ) { return $otp_token; }
285                 elsif ( $param eq 'logout.x' )  { return $logout; }
286                 else                            { return; }
287             }
288         );
289         $cgi->mock( 'request_method', sub { return 'POST' } );
290         $cgi->mock( 'cookie', sub { return $sessionID } );
291
292         my $two_factor_auth = Test::MockModule->new( 'Koha::Auth::TwoFactorAuth' );
293         $two_factor_auth->mock( 'verify', sub {$verified} );
294
295         my ( $userid, $cookie, $flags );
296         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
297
298         sub logout {
299             my $cgi = shift;
300             $logout = 1;
301             undef $sessionID;
302             C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
303             $logout = 0;
304         }
305
306         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'disabled' );
307         $patron->auth_method('password')->store;
308         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
309         is( $userid, $patron->userid, 'Succesful login' );
310         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
311         logout($cgi);
312
313         $patron->auth_method('two-factor')->store;
314         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
315         is( $userid, $patron->userid, 'Succesful login' );
316         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'Second auth not required' );
317         logout($cgi);
318
319         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'enabled' );
320         t::lib::Mocks::mock_config('encryption_key', '1234tH1s=t&st');
321         $patron->auth_method('password')->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         $patron->encode_secret('one_secret');
328         $patron->auth_method('two-factor');
329         $patron->store;
330         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
331         is( $userid, $patron->userid, 'Succesful login' );
332         my $session = C4::Auth::get_session($sessionID);
333         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 1, 'Second auth required' );
334
335         # Wrong OTP token
336         $otp_token = "wrong";
337         $verified = 0;
338         $patron->auth_method('two-factor')->store;
339         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
340         is( $userid, $patron->userid, 'Succesful login' );
341         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 1, 'Second auth still required after wrong OTP token' );
342
343         $otp_token = "good";
344         $verified = 1;
345         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
346         is( $userid, $patron->userid, 'Succesful login' );
347         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), 0, 'Second auth no longer required if OTP token has been verified' );
348         logout($cgi);
349
350         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'enforced' );
351         $patron->auth_method('password')->store;
352         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'intranet' );
353         is( $userid, $patron->userid, 'Succesful login' );
354         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA-setup'), 1, 'Setup 2FA required' );
355         logout($cgi);
356
357         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired', undef, 'opac' );
358         is( $userid, $patron->userid, 'Succesful login at the OPAC' );
359         is( C4::Auth::get_session($sessionID)->param('waiting-for-2FA'), undef, 'No second auth required at the OPAC' );
360
361         #
362         t::lib::Mocks::mock_preference( 'TwoFactorAuthentication', 'disabled' );
363         $session = C4::Auth::get_session($sessionID);
364         $session->param('waiting-for-2FA', 1);
365         $session->flush;
366         my ($auth_status, undef ) = C4::Auth::check_cookie_auth($sessionID, undef );
367         is( $auth_status, 'ok', 'User authenticated, pref was disabled, access OK' );
368         $session->param('waiting-for-2FA', 0);
369         $session->param('waiting-for-2FA-setup', 1);
370         $session->flush;
371         ($auth_status, undef ) = C4::Auth::check_cookie_auth($sessionID, undef );
372         is( $auth_status, 'ok', 'User waiting for 2FA setup, pref was disabled, access OK' );
373     };
374
375     subtest 'loggedinlibrary permission tests' => sub {
376
377         plan tests => 3;
378         my $staff_user = $builder->build_object(
379             { class => 'Koha::Patrons', value => { flags => 536870916 } } );
380
381         my $branch = $builder->build_object({ class => 'Koha::Libraries' });
382
383         my $password = 'password';
384         t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
385         $staff_user->set_password( { password => $password } );
386         my $cgi = Test::MockObject->new();
387         $cgi->mock( 'cookie', sub { return; } );
388         $cgi->mock(
389             'param',
390             sub {
391                 my ( $self, $param ) = @_;
392                 if    ( $param eq 'userid' )   { return $staff_user->userid; }
393                 elsif ( $param eq 'password' ) { return $password; }
394                 elsif ( $param eq 'branch' )   { return $branch->branchcode; }
395                 else                           { return; }
396             }
397         );
398
399         $cgi->mock( 'request_method', sub { return 'POST' } );
400         my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
401         my $sesh = C4::Auth::get_session($sessionID);
402         is( $sesh->param('branch'), $branch->branchcode, "If user has permission, they should be able to choose a branch" );
403
404         $staff_user->flags(4)->store->discard_changes;
405         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
406         $sesh = C4::Auth::get_session($sessionID);
407         is( $sesh->param('branch'), $staff_user->branchcode, "If user has not permission, they should not be able to choose a branch" );
408
409         $staff_user->flags(1)->store->discard_changes;
410         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 'authrequired' );
411         $sesh = C4::Auth::get_session($sessionID);
412         is( $sesh->param('branch'), $branch->branchcode, "If user is superlibrarian, they should be able to choose a branch" );
413
414     };
415     C4::Context->_new_userenv; # For next tests
416 };
417
418 subtest 'track_login_daily tests' => sub {
419
420     plan tests => 5;
421
422     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
423     my $userid = $patron->userid;
424
425     $patron->lastseen( undef );
426     $patron->store();
427
428     my $cache     = Koha::Caches->get_instance();
429     my $cache_key = "track_login_" . $patron->userid;
430     $cache->clear_from_cache($cache_key);
431
432     t::lib::Mocks::mock_preference( 'TrackLastPatronActivity', '1' );
433
434     is( $patron->lastseen, undef, 'Patron should have not last seen when newly created' );
435
436     C4::Auth::track_login_daily( $userid );
437     $patron->_result()->discard_changes();
438     isnt( $patron->lastseen, undef, 'Patron should have last seen set when TrackLastPatronActivity = 1' );
439
440     sleep(1); # We need to wait a tiny bit to make sure the timestamp will be different
441     my $last_seen = $patron->lastseen;
442     C4::Auth::track_login_daily( $userid );
443     $patron->_result()->discard_changes();
444     is( $patron->lastseen, $last_seen, 'Patron last seen should still be unchanged' );
445
446     $cache->clear_from_cache($cache_key);
447     C4::Auth::track_login_daily( $userid );
448     $patron->_result()->discard_changes();
449     isnt( $patron->lastseen, $last_seen, 'Patron last seen should be changed if we cleared the cache' );
450
451     t::lib::Mocks::mock_preference( 'TrackLastPatronActivity', '0' );
452     $patron->lastseen( undef )->store;
453     $cache->clear_from_cache($cache_key);
454     C4::Auth::track_login_daily( $userid );
455     $patron->_result()->discard_changes();
456     is( $patron->lastseen, undef, 'Patron should still have last seen unchanged when TrackLastPatronActivity = 0' );
457
458 };
459
460 subtest 'no_set_userenv parameter tests' => sub {
461
462     plan tests => 7;
463
464     my $library = $builder->build_object( { class => 'Koha::Libraries' } );
465     my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
466     my $password = 'password';
467
468     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
469     $patron->set_password({ password => $password });
470
471     ok( checkpw( $patron->userid, $password, undef, undef, 1 ), 'checkpw returns true' );
472     is( C4::Context->userenv, undef, 'Userenv should be undef as required' );
473     C4::Context->_new_userenv('DUMMY SESSION');
474     C4::Context->set_userenv(0,0,0,'firstname','surname', $library->branchcode, 'Library 1', 0, '', '');
475     is( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv gives correct branch' );
476     ok( checkpw( $patron->userid, $password, undef, undef, 1 ), 'checkpw returns true' );
477     is( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv branch is preserved if no_set_userenv is true' );
478     ok( checkpw( $patron->userid, $password, undef, undef, 0 ), 'checkpw still returns true' );
479     isnt( C4::Context->userenv->{branch}, $library->branchcode, 'Userenv branch is overwritten if no_set_userenv is false' );
480 };
481
482 subtest 'checkpw lockout tests' => sub {
483
484     plan tests => 5;
485
486     my $library = $builder->build_object( { class => 'Koha::Libraries' } );
487     my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
488     my $password = 'password';
489     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
490     t::lib::Mocks::mock_preference( 'FailedLoginAttempts', 1 );
491     $patron->set_password({ password => $password });
492
493     my ( $checkpw, undef, undef ) = checkpw( $patron->cardnumber, $password, undef, undef, 1 );
494     ok( $checkpw, 'checkpw returns true with right password when logging in via cardnumber' );
495     ( $checkpw, undef, undef ) = checkpw( $patron->userid, "wrong_password", undef, undef, 1 );
496     is( $checkpw, 0, 'checkpw returns false when given wrong password' );
497     $patron = $patron->get_from_storage;
498     is( $patron->account_locked, 1, "Account is locked from failed login");
499     ( $checkpw, undef, undef ) = checkpw( $patron->userid, $password, undef, undef, 1 );
500     is( $checkpw, undef, 'checkpw returns undef with right password when account locked' );
501     ( $checkpw, undef, undef ) = checkpw( $patron->cardnumber, $password, undef, undef, 1 );
502     is( $checkpw, undef, 'checkpw returns undefwith right password when logging in via cardnumber if account locked' );
503
504 };
505
506 # get_template_and_user tests
507
508 subtest 'get_template_and_user' => sub {   # Tests for the language URL parameter
509
510     sub MockedCheckauth {
511         my ($query,$authnotrequired,$flagsrequired,$type) = @_;
512         # return vars
513         my $userid = 'cobain';
514         my $sessionID = 234;
515         # we don't need to bother about permissions for this test
516         my $flags = {
517             superlibrarian    => 1, acquisition       => 0,
518             borrowers         => 0,
519             catalogue         => 1, circulate         => 0,
520             coursereserves    => 0, editauthorities   => 0,
521             editcatalogue     => 0,
522             parameters        => 0, permissions       => 0,
523             plugins           => 0, reports           => 0,
524             reserveforothers  => 0, serials           => 0,
525             staffaccess       => 0, tools             => 0,
526             updatecharges     => 0
527         };
528
529         my $session_cookie = $query->cookie(
530             -name => 'CGISESSID',
531             -value    => 'nirvana',
532             -HttpOnly => 1
533         );
534
535         return ( $userid, [ $session_cookie ], $sessionID, $flags );
536     }
537
538     # Mock checkauth, build the scenario
539     my $auth = Test::MockModule->new( 'C4::Auth' );
540     $auth->mock( 'checkauth', \&MockedCheckauth );
541
542     # Make sure 'EnableOpacSearchHistory' is set
543     t::lib::Mocks::mock_preference('EnableOpacSearchHistory',1);
544     # Enable es-ES for the OPAC and staff interfaces
545     t::lib::Mocks::mock_preference('OPACLanguages','en,es-ES');
546     t::lib::Mocks::mock_preference('language','en,es-ES');
547
548     # we need a session cookie
549     $ENV{"SERVER_PORT"} = 80;
550     $ENV{"HTTP_COOKIE"} = 'CGISESSID=nirvana';
551
552     my $query = CGI->new;
553     $query->param('language','es-ES');
554
555     my ( $template, $loggedinuser, $cookies ) = get_template_and_user(
556         {
557             template_name   => "about.tt",
558             query           => $query,
559             type            => "opac",
560             authnotrequired => 1,
561             flagsrequired   => { catalogue => 1 },
562             debug           => 1
563         }
564     );
565
566     ok ( ( all { ref($_) eq 'CGI::Cookie' } @$cookies ),
567             'BZ9735: the cookies array is flat' );
568
569     # new query, with non-existent language (we only have en and es-ES)
570     $query->param('language','tomas');
571
572     ( $template, $loggedinuser, $cookies ) = get_template_and_user(
573         {
574             template_name   => "about.tt",
575             query           => $query,
576             type            => "opac",
577             authnotrequired => 1,
578             flagsrequired   => { catalogue => 1 },
579             debug           => 1
580         }
581     );
582
583     ok( ( none { $_->name eq 'KohaOpacLanguage' and $_->value eq 'tomas' } @$cookies ),
584         'BZ9735: invalid language, it is not set');
585
586     ok( ( any { $_->name eq 'KohaOpacLanguage' and $_->value eq 'en' } @$cookies ),
587         'BZ9735: invalid language, then default to en');
588
589     for my $template_name (
590         qw(
591             ../../../../../../../../../../../../../../../etc/passwd
592             test/../../../../../../../../../../../../../../etc/passwd
593             /etc/passwd
594             test/does_not_finished_by_tt_t
595         )
596     ) {
597         eval {
598             ( $template, $loggedinuser, $cookies ) = get_template_and_user(
599                 {
600                     template_name   => $template_name,
601                     query           => $query,
602                     type            => "intranet",
603                     authnotrequired => 1,
604                     flagsrequired   => { catalogue => 1 },
605                 }
606             );
607         };
608         like ( $@, qr(bad template path), "The file $template_name should not be accessible" );
609     }
610     ( $template, $loggedinuser, $cookies ) = get_template_and_user(
611         {
612             template_name   => 'errors/errorpage.tt',
613             query           => $query,
614             type            => "intranet",
615             authnotrequired => 1,
616             flagsrequired   => { catalogue => 1 },
617         }
618     );
619     my $file_exists = ( -f $template->{filename} ) ? 1 : 0;
620     is ( $file_exists, 1, 'The file errors/errorpage.tt should be accessible (contains integers)' );
621
622     # Regression test for env opac search limit override
623     $ENV{"OPAC_SEARCH_LIMIT"} = "branch:CPL";
624     $ENV{"OPAC_LIMIT_OVERRIDE"} = 1;
625
626     ( $template, $loggedinuser, $cookies) = get_template_and_user(
627         {
628             template_name => 'opac-main.tt',
629             query => $query,
630             type => 'opac',
631             authnotrequired => 1,
632         }
633     );
634     is($template->{VARS}->{'opac_name'}, "CPL", "Opac name was set correctly");
635     is($template->{VARS}->{'opac_search_limit'}, "branch:CPL", "Search limit was set correctly");
636
637     $ENV{"OPAC_SEARCH_LIMIT"} = "branch:multibranch-19";
638
639     ( $template, $loggedinuser, $cookies) = get_template_and_user(
640         {
641             template_name => 'opac-main.tt',
642             query => $query,
643             type => 'opac',
644             authnotrequired => 1,
645         }
646     );
647     is($template->{VARS}->{'opac_name'}, "multibranch-19", "Opac name was set correctly");
648     is($template->{VARS}->{'opac_search_limit'}, "branch:multibranch-19", "Search limit was set correctly");
649
650     delete $ENV{"HTTP_COOKIE"};
651 };
652
653 # Check that there is always an OPACBaseURL set.
654 my $input = CGI->new();
655 my ( $template1, $borrowernumber, $cookie );
656 ( $template1, $borrowernumber, $cookie ) = get_template_and_user(
657     {
658         template_name => "opac-detail.tt",
659         type => "opac",
660         query => $input,
661         authnotrequired => 1,
662     }
663 );
664
665 ok( ( any { 'OPACBaseURL' eq $_ } keys %{$template1->{VARS}} ),
666     'OPACBaseURL is in OPAC template' );
667
668 my ( $template2 );
669 ( $template2, $borrowernumber, $cookie ) = get_template_and_user(
670     {
671         template_name => "catalogue/detail.tt",
672         type => "intranet",
673         query => $input,
674         authnotrequired => 1,
675     }
676 );
677
678 ok( ( any { 'OPACBaseURL' eq $_ } keys %{$template2->{VARS}} ),
679     'OPACBaseURL is in Staff template' );
680
681 my $hash1 = hash_password('password');
682 my $hash2 = hash_password('password');
683
684 ok(C4::Auth::checkpw_hash('password', $hash1), 'password validates with first hash');
685 ok(C4::Auth::checkpw_hash('password', $hash2), 'password validates with second hash');
686
687 subtest 'Check value of login_attempts in checkpw' => sub {
688     plan tests => 11;
689
690     t::lib::Mocks::mock_preference('FailedLoginAttempts', 3);
691
692     # Only interested here in regular login
693     $C4::Auth::cas  = 0;
694     $C4::Auth::ldap = 0;
695
696     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
697     $patron->login_attempts(2);
698     $patron->password('123')->store; # yes, deliberately not hashed
699
700     is( $patron->account_locked, 0, 'Patron not locked' );
701     my @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
702         # Note: 123 will not be hashed to 123 !
703     is( $test[0], 0, 'checkpw should have failed' );
704     $patron->discard_changes; # refresh
705     is( $patron->login_attempts, 3, 'Login attempts increased' );
706     is( $patron->account_locked, 1, 'Check locked status' );
707
708     # And another try to go over the limit: different return value!
709     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
710     is( @test, 0, 'checkpw failed again and returns nothing now' );
711     $patron->discard_changes; # refresh
712     is( $patron->login_attempts, 3, 'Login attempts not increased anymore' );
713
714     # Administrative lockout cannot be undone?
715     # Pass the right password now (or: add a nice mock).
716     my $auth = Test::MockModule->new( 'C4::Auth' );
717     $auth->mock( 'checkpw_hash', sub { return 1; } ); # not for production :)
718     $patron->login_attempts(0)->store;
719     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
720     is( $test[0], 1, 'Build confidence in the mock' );
721     $patron->login_attempts(-1)->store;
722     is( $patron->account_locked, 1, 'Check administrative lockout' );
723     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
724     is( @test, 0, 'checkpw gave red' );
725     $patron->discard_changes; # refresh
726     is( $patron->login_attempts, -1, 'Still locked out' );
727     t::lib::Mocks::mock_preference('FailedLoginAttempts', ''); # disable
728     is( $patron->account_locked, 1, 'Check administrative lockout without pref' );
729 };
730
731 subtest 'Check value of login_attempts in checkpw' => sub {
732     plan tests => 2;
733
734     t::lib::Mocks::mock_preference('FailedLoginAttempts', 3);
735     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
736     $patron->set_password({ password => '123', skip_validation => 1 });
737
738     my @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
739     is( $test[0], 1, 'Patron authenticated correctly' );
740
741     $patron->password_expiration_date('2020-01-01')->store;
742     @test = checkpw( $patron->userid, '123', undef, 'opac', 1 );
743     is( $test[0], -2, 'Patron returned as expired correctly' );
744
745 };
746
747 subtest '_timeout_syspref' => sub {
748
749     plan tests => 6;
750
751     t::lib::Mocks::mock_preference('timeout', "100");
752     is( C4::Auth::_timeout_syspref, 100, );
753
754     t::lib::Mocks::mock_preference('timeout', "2d");
755     is( C4::Auth::_timeout_syspref, 2*86400, );
756
757     t::lib::Mocks::mock_preference('timeout', "2D");
758     is( C4::Auth::_timeout_syspref, 2*86400, );
759
760     t::lib::Mocks::mock_preference('timeout', "10h");
761     is( C4::Auth::_timeout_syspref, 10*3600, );
762
763     t::lib::Mocks::mock_preference('timeout', "10x");
764     warning_is
765         { is( C4::Auth::_timeout_syspref, 600, ); }
766         "The value of the system preference 'timeout' is not correct, defaulting to 600",
767         'Bad values throw a warning and fallback to 600';
768 };
769
770 subtest 'check_cookie_auth' => sub {
771     plan tests => 4;
772
773     t::lib::Mocks::mock_preference('timeout', "1d"); # back to default
774
775     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 1 } });
776
777     # Mock a CGI object with real userid param
778     my $cgi = Test::MockObject->new();
779     $cgi->mock(
780         'param',
781         sub {
782             my $var = shift;
783             if ( $var eq 'userid' ) { return $patron->userid; }
784         }
785     );
786     $cgi->mock('multi_param', sub {return q{}} );
787     $cgi->mock( 'cookie', sub { return; } );
788     $cgi->mock( 'request_method', sub { return 'POST' } );
789
790     $ENV{REMOTE_ADDR} = '127.0.0.1';
791
792     # Setting authnotrequired=1 or we wont' hit the return but the end of the sub that prints headers
793     my ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 1 );
794
795     my ($auth_status, $session) = C4::Auth::check_cookie_auth($sessionID);
796     isnt( $auth_status, 'ok', 'check_cookie_auth should not return ok if the user has not been authenticated before if no permissions needed' );
797     is( $auth_status, 'anon', 'check_cookie_auth should return anon if the user has not been authenticated before and no permissions needed' );
798
799     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth( $cgi, 1 );
800
801     ($auth_status, $session) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
802     isnt( $auth_status, 'ok', 'check_cookie_auth should not return ok if the user has not been authenticated before and permissions needed' );
803     is( $auth_status, 'anon', 'check_cookie_auth should return anon if the user has not been authenticated before and permissions needed' );
804
805     #FIXME We should have a test to cover 'failed' status when a user has logged in, but doesn't have permission
806 };
807
808 subtest 'checkauth & check_cookie_auth' => sub {
809     plan tests => 35;
810
811     # flags = 4 => { catalogue => 1 }
812     my $patron = $builder->build_object({ class => 'Koha::Patrons', value => { flags => 4 } });
813     my $password = 'password';
814     t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
815     $patron->set_password( { password => $password } );
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, checkauth didn't return the session
843     {
844         local *STDOUT;
845         my $stdout;
846         open STDOUT, '>', \$stdout;
847         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1} );
848         close STDOUT;
849     }
850     is( $sessionID, undef);
851     is( $userid, undef);
852
853     # Sending undefined fails obviously
854     my ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1} );
855     is( $auth_status, 'failed' );
856     is( $session, undef );
857
858     # Simulating the login form submission
859     $cgi->param('userid', $patron->userid);
860     $cgi->param('password', $password);
861
862     # Logged in!
863     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
864     is( $sessionID, $first_sessionID );
865     is( $userid, $patron->userid );
866
867     ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
868     is( $auth_status, 'ok' );
869     is( $session->id, $first_sessionID );
870
871     my $patron_to_delete = $builder->build_object({ class => 'Koha::Patrons' });
872     my $fresh_userid = $patron_to_delete->userid;
873     $patron_to_delete->delete;
874     my $old_userid   = $patron->userid;
875
876     # change the current session user's userid
877     $patron->userid( $fresh_userid )->store;
878     ( $auth_status, $session ) = C4::Auth::check_cookie_auth($sessionID, {catalogue => 1});
879     is( $auth_status, 'expired' );
880     is( $session, undef );
881
882     # restore userid and generate a new session
883     $patron->userid($old_userid)->store;
884     ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
885     is( $sessionID, $first_sessionID );
886     is( $userid, $patron->userid );
887
888     # Logging out!
889     $cgi->param('logout.x', 1);
890     $cgi->delete( 'userid', 'password' );
891     {
892         local *STDOUT;
893         my $stdout;
894         open STDOUT, '>', \$stdout;
895         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
896         close STDOUT;
897     }
898     is( $sessionID, undef );
899     is( $ENV{"HTTP_COOKIE"}, "CGISESSID=$first_sessionID", 'HTTP_COOKIE not unset' );
900     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, {catalogue => 1} );
901     is( $auth_status, "expired");
902     is( $session, undef );
903
904     {
905         # Trying to access without sessionID
906         $cgi = CGI->new;
907         ( $auth_status, $session) = C4::Auth::check_cookie_auth(undef, {catalogue => 1});
908         is( $auth_status, 'failed' );
909         is( $session, undef );
910
911         # This will fail on permissions
912         undef $ENV{"HTTP_COOKIE"};
913         {
914             local *STDOUT;
915             my $stdout;
916             open STDOUT, '>', \$stdout;
917             ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1} );
918             close STDOUT;
919         }
920         is( $userid, undef );
921         is( $sessionID, undef );
922     }
923
924     {
925         # First logging in
926         $cgi = CGI->new;
927         $cgi->param('userid', $patron->userid);
928         $cgi->param('password', $password);
929         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
930         is( $userid, $patron->userid );
931         $first_sessionID = $sessionID;
932
933         # Patron does not have the borrowers permission
934         # $ENV{"HTTP_COOKIE"} = "CGISESSID=$sessionID"; # not needed, we use $cgi here
935         {
936             local *STDOUT;
937             my $stdout;
938             open STDOUT, '>', \$stdout;
939             ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {borrowers => 1} );
940             close STDOUT;
941         }
942         is( $userid, undef );
943         is( $sessionID, undef );
944
945         # When calling check_cookie_auth, the session will be deleted
946         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, { borrowers => 1 } );
947         is( $auth_status, "failed" );
948         is( $session, undef );
949         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $first_sessionID, { borrowers => 1 } );
950         is( $auth_status, 'expired', 'Session no longer exists' );
951
952         # NOTE: It is not what the UI is doing.
953         # From the UI we are allowed to hit an unauthorized page then reuse the session to hit back authorized area.
954         # It is because check_cookie_auth is ALWAYS called from checkauth WITHOUT $flagsrequired
955         # It then return "ok", when the previous called got "failed"
956
957         # Try reusing the deleted session: since it does not exist, we should get a new one now when passing correct permissions
958         $cgi->cookie( -name => 'CGISESSID', value => $first_sessionID );
959         ( $userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 0, {catalogue => 1});
960         is( $userid, $patron->userid );
961         isnt( $sessionID, undef, 'Check if we have a sessionID' );
962         isnt( $sessionID, $first_sessionID, 'New value expected' );
963         ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID, {catalogue => 1} );
964         is( $auth_status, "ok" );
965         is( $session->id, $sessionID, 'Same session' );
966         # Two additional tests on userenv
967         is( $C4::Context::context->{activeuser}, $session->id, 'Check if environment has been setup for session' );
968         is( C4::Context->userenv->{id}, $userid, 'Check userid in userenv' );
969     }
970 };
971
972 subtest 'Userenv clearing in check_cookie_auth' => sub {
973     # Note: We did already test userenv for a logged-in user in previous subtest
974     plan tests => 9;
975
976     t::lib::Mocks::mock_preference( 'timeout', 600 );
977     my $cgi = CGI->new;
978
979     # Create a new anonymous session by passing a fake session ID
980     $cgi->cookie( -name => 'CGISESSID', -value => 'fake_sessionID' );
981     my ($userid, $cookie, $sessionID, $flags ) = C4::Auth::checkauth($cgi, 1);
982     my ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
983     is( $auth_status, 'anon', 'Should be anonymous' );
984     is( $C4::Context::context->{activeuser}, $session->id, 'Check activeuser' );
985     is( defined C4::Context->userenv, 1, 'There should be a userenv' );
986     is(  C4::Context->userenv->{id}, q{}, 'userid should be empty string' );
987
988     # Make the session expire now, check_cookie_auth will delete it
989     $session->param('lasttime', time() - 1200 );
990     $session->flush;
991     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
992     is( $auth_status, 'expired', 'Should be expired' );
993     is( C4::Context->userenv, undef, 'Environment should be cleared too' );
994
995     # Show that we clear the userenv again: set up env and check deleted session
996     C4::Context->_new_userenv( $sessionID );
997     C4::Context->set_userenv; # empty
998     is( defined C4::Context->userenv, 1, 'There should be an empty userenv again' );
999     ( $auth_status, $session) = C4::Auth::check_cookie_auth( $sessionID );
1000     is( $auth_status, 'expired', 'Should be expired already' );
1001     is( C4::Context->userenv, undef, 'Environment should be cleared again' );
1002 };
1003
1004 subtest 'create_basic_session tests' => sub {
1005     plan tests => 13;
1006
1007     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
1008
1009     my $session = C4::Auth::create_basic_session({ patron => $patron, interface => 'opac' });
1010
1011     isnt($session->id, undef, 'A new sessionID was created');
1012     is( $session->param('number'), $patron->borrowernumber, 'Session parameter number matches' );
1013     is( $session->param('id'), $patron->userid, 'Session parameter id matches' );
1014     is( $session->param('cardnumber'), $patron->cardnumber, 'Session parameter cardnumber matches' );
1015     is( $session->param('firstname'), $patron->firstname, 'Session parameter firstname matches' );
1016     is( $session->param('surname'), $patron->surname, 'Session parameter surname matches' );
1017     is( $session->param('branch'), $patron->branchcode, 'Session parameter branch matches' );
1018     is( $session->param('branchname'), $patron->library->branchname, 'Session parameter branchname matches' );
1019     is( $session->param('flags'), $patron->flags, 'Session parameter flags matches' );
1020     is( $session->param('emailaddress'), $patron->email, 'Session parameter emailaddress matches' );
1021     is( $session->param('ip'), $session->remote_addr(), 'Session parameter ip matches' );
1022     is( $session->param('interface'), 'opac', 'Session parameter interface matches' );
1023
1024     $session = C4::Auth::create_basic_session({ patron => $patron, interface => 'staff' });
1025     is( $session->param('interface'), 'intranet', 'Staff interface gets converted to intranet' );
1026 };
1027
1028 subtest 'check_cookie_auth overwriting interface already set' => sub {
1029     plan tests => 2;
1030
1031     t::lib::Mocks::mock_preference( 'SessionRestrictionByIP', 0 );
1032
1033     my $patron = $builder->build_object({ class => 'Koha::Patrons' });
1034     my $session = C4::Auth::get_session();
1035     $session->param( 'number',       $patron->id );
1036     $session->param( 'id',           $patron->userid );
1037     $session->param( 'ip',           '1.2.3.4' );
1038     $session->param( 'lasttime',     time() );
1039     $session->param( 'interface',    'opac' );
1040     $session->flush;
1041
1042     C4::Context->interface('intranet');
1043     C4::Auth::check_cookie_auth( $session->id );
1044     is( C4::Context->interface, 'intranet', 'check_cookie_auth did not overwrite' );
1045     delete $C4::Context::context->{interface}; # clear context interface
1046     C4::Auth::check_cookie_auth( $session->id );
1047     is( C4::Context->interface, 'opac', 'check_cookie_auth used interface from session when context interface was empty' );
1048
1049     t::lib::Mocks::mock_preference( 'SessionRestrictionByIP', 1 );
1050 };
1051
1052 $schema->storage->txn_rollback;
1053
1054 subtest 'get_cataloguing_page_permissions() tests' => sub {
1055
1056     plan tests => 6;
1057
1058     $schema->storage->txn_begin;
1059
1060     my $patron = $builder->build_object( { class => 'Koha::Patrons', value => { flags => 2**2 } } );    # catalogue
1061
1062     ok(
1063         !C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1064         '"catalogue" is not enough to see the cataloguing page'
1065     );
1066
1067     $builder->build(
1068         {
1069             source => 'UserPermission',
1070             value  => {
1071                 borrowernumber => $patron->id,
1072                 module_bit     => 24,               # stockrotation
1073                 code           => 'manage_rotas',
1074             },
1075         }
1076     );
1077
1078     t::lib::Mocks::mock_preference( 'StockRotation', 1 );
1079     ok(
1080         C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1081         '"stockrotation => manage_rotas" is enough'
1082     );
1083
1084     t::lib::Mocks::mock_preference( 'StockRotation', 0 );
1085     ok(
1086         !C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1087         '"stockrotation => manage_rotas" is not enough when `StockRotation` is disabled'
1088     );
1089
1090     $builder->build(
1091         {
1092             source => 'UserPermission',
1093             value  => {
1094                 borrowernumber => $patron->id,
1095                 module_bit     => 13,                     # tools
1096                 code           => 'manage_staged_marc',
1097             },
1098         }
1099     );
1100
1101     ok(
1102         C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1103         'Having one of the listed `tools` subperm is enough'
1104     );
1105
1106     $schema->resultset('UserPermission')->search( { borrowernumber => $patron->id } )->delete;
1107
1108     ok(
1109         !C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1110         'Permission removed, no access'
1111     );
1112
1113     $builder->build(
1114         {
1115             source => 'UserPermission',
1116             value  => {
1117                 borrowernumber => $patron->id,
1118                 module_bit     => 9,                    # editcatalogue
1119                 code           => 'delete_all_items',
1120             },
1121         }
1122     );
1123
1124     ok(
1125         C4::Auth::haspermission( $patron->userid, get_cataloguing_page_permissions() ),
1126         'Having any `editcatalogue` subperm is enough'
1127     );
1128
1129     $schema->storage->txn_rollback;
1130 };