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