1 <?php namespace Tests\Auth;
3 use BookStack\Auth\Access\LdapService;
4 use BookStack\Auth\Role;
5 use BookStack\Auth\Access\Ldap;
6 use BookStack\Auth\User;
7 use BookStack\Exceptions\LdapException;
8 use Mockery\MockInterface;
9 use Tests\BrowserKitTest;
11 class LdapTest extends BrowserKitTest
20 protected $resourceId = 'resource-test';
22 public function setUp(): void
25 if (!defined('LDAP_OPT_REFERRALS')) define('LDAP_OPT_REFERRALS', 1);
27 'auth.method' => 'ldap',
28 'auth.defaults.guard' => 'ldap',
29 'services.ldap.base_dn' => 'dc=ldap,dc=local',
30 'services.ldap.email_attribute' => 'mail',
31 'services.ldap.display_name_attribute' => 'cn',
32 'services.ldap.id_attribute' => 'uid',
33 'services.ldap.user_to_groups' => false,
34 'services.ldap.version' => '3',
35 'services.ldap.user_filter' => '(&(uid=${user}))',
36 'services.ldap.follow_referrals' => false,
37 'services.ldap.tls_insecure' => false,
39 $this->mockLdap = \Mockery::mock(Ldap::class);
40 $this->app[Ldap::class] = $this->mockLdap;
41 $this->mockUser = factory(User::class)->make();
44 protected function runFailedAuthLogin()
46 $this->commonLdapMocks(1, 1, 1, 1, 1);
47 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
48 ->andReturn(['count' => 0]);
49 $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
52 protected function mockEscapes($times = 1)
54 $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function($val) {
55 return ldap_escape($val);
59 protected function mockExplodes($times = 1)
61 $this->mockLdap->shouldReceive('explodeDn')->times($times)->andReturnUsing(function($dn, $withAttrib) {
62 return ldap_explode_dn($dn, $withAttrib);
66 protected function mockUserLogin()
68 return $this->visit('/login')
70 ->type($this->mockUser->name, '#username')
71 ->type($this->mockUser->password, '#password')
76 * Set LDAP method mocks for things we commonly call without altering.
78 protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0)
80 $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId);
81 $this->mockLdap->shouldReceive('setVersion')->times($versions);
82 $this->mockLdap->shouldReceive('setOption')->times($options);
83 $this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true);
84 $this->mockEscapes($escapes);
85 $this->mockExplodes($explodes);
88 public function test_login()
90 $this->commonLdapMocks(1, 1, 2, 4, 2);
91 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
92 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
93 ->andReturn(['count' => 1, 0 => [
94 'uid' => [$this->mockUser->name],
95 'cn' => [$this->mockUser->name],
96 'dn' => ['dc=test' . config('services.ldap.base_dn')]
99 $this->mockUserLogin()
100 ->seePageIs('/login')->see('Please enter an email to use for this account.');
102 $this->type($this->mockUser->email, '#email')
105 ->see($this->mockUser->name)
106 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name]);
109 public function test_email_domain_restriction_active_on_new_ldap_login()
112 'registration-restrict' => 'testing.com'
115 $this->commonLdapMocks(1, 1, 2, 4, 2);
116 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
117 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
118 ->andReturn(['count' => 1, 0 => [
119 'uid' => [$this->mockUser->name],
120 'cn' => [$this->mockUser->name],
121 'dn' => ['dc=test' . config('services.ldap.base_dn')]
124 $this->mockUserLogin()
125 ->seePageIs('/login')
126 ->see('Please enter an email to use for this account.');
130 $this->type($email, '#email')
132 ->seePageIs('/login')
133 ->see('That email domain does not have access to this application')
134 ->dontSeeInDatabase('users', ['email' => $email]);
137 public function test_login_works_when_no_uid_provided_by_ldap_server()
139 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
141 $this->commonLdapMocks(1, 1, 1, 2, 1);
142 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
143 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
144 ->andReturn(['count' => 1, 0 => [
145 'cn' => [$this->mockUser->name],
147 'mail' => [$this->mockUser->email]
150 $this->mockUserLogin()
152 ->see($this->mockUser->name)
153 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]);
156 public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly()
158 config()->set(['services.ldap.id_attribute' => 'my_custom_id']);
160 $this->commonLdapMocks(1, 1, 1, 2, 1);
161 $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
162 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
163 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
164 ->andReturn(['count' => 1, 0 => [
165 'cn' => [$this->mockUser->name],
167 'my_custom_id' => ['cooluser456'],
168 'mail' => [$this->mockUser->email]
172 $this->mockUserLogin()
174 ->see($this->mockUser->name)
175 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']);
178 public function test_initial_incorrect_credentials()
180 $this->commonLdapMocks(1, 1, 1, 0, 1);
181 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
182 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
183 ->andReturn(['count' => 1, 0 => [
184 'uid' => [$this->mockUser->name],
185 'cn' => [$this->mockUser->name],
186 'dn' => ['dc=test' . config('services.ldap.base_dn')]
188 $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false);
190 $this->mockUserLogin()
191 ->seePageIs('/login')->see('These credentials do not match our records.')
192 ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]);
195 public function test_login_not_found_username()
197 $this->commonLdapMocks(1, 1, 1, 1, 1);
198 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
199 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
200 ->andReturn(['count' => 0]);
202 $this->mockUserLogin()
203 ->seePageIs('/login')->see('These credentials do not match our records.')
204 ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]);
208 public function test_create_user_form()
210 $this->asAdmin()->visit('/settings/users/create')
211 ->dontSee('Password')
212 ->type($this->mockUser->name, '#name')
213 ->type($this->mockUser->email, '#email')
215 ->see('The external auth id field is required.')
216 ->type($this->mockUser->name, '#external_auth_id')
218 ->seePageIs('/settings/users')
219 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'external_auth_id' => $this->mockUser->name, 'email_confirmed' => true]);
222 public function test_user_edit_form()
224 $editUser = $this->getNormalUser();
225 $this->asAdmin()->visit('/settings/users/' . $editUser->id)
227 ->dontSee('Password')
228 ->type('test_auth_id', '#external_auth_id')
230 ->seePageIs('/settings/users')
231 ->seeInDatabase('users', ['email' => $editUser->email, 'external_auth_id' => 'test_auth_id']);
234 public function test_registration_disabled()
236 $this->visit('/register')
237 ->seePageIs('/login');
240 public function test_non_admins_cannot_change_auth_id()
242 $testUser = $this->getNormalUser();
243 $this->actingAs($testUser)->visit('/settings/users/' . $testUser->id)
244 ->dontSee('External Authentication');
247 public function test_login_maps_roles_and_retains_existing_roles()
249 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
250 $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']);
251 $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']);
252 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
253 $this->mockUser->attachRole($existingRole);
256 'services.ldap.user_to_groups' => true,
257 'services.ldap.group_attribute' => 'memberOf',
258 'services.ldap.remove_from_groups' => false,
261 $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
262 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
263 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
264 ->andReturn(['count' => 1, 0 => [
265 'uid' => [$this->mockUser->name],
266 'cn' => [$this->mockUser->name],
267 'dn' => ['dc=test' . config('services.ldap.base_dn')],
268 'mail' => [$this->mockUser->email],
271 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
272 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com",
276 $this->mockUserLogin()->seePageIs('/');
278 $user = User::where('email', $this->mockUser->email)->first();
279 $this->seeInDatabase('role_user', [
280 'user_id' => $user->id,
281 'role_id' => $roleToReceive->id
283 $this->seeInDatabase('role_user', [
284 'user_id' => $user->id,
285 'role_id' => $roleToReceive2->id
287 $this->seeInDatabase('role_user', [
288 'user_id' => $user->id,
289 'role_id' => $existingRole->id
293 public function test_login_maps_roles_and_removes_old_roles_if_set()
295 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
296 $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']);
297 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
298 $this->mockUser->attachRole($existingRole);
301 'services.ldap.user_to_groups' => true,
302 'services.ldap.group_attribute' => 'memberOf',
303 'services.ldap.remove_from_groups' => true,
306 $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
307 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
308 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
309 ->andReturn(['count' => 1, 0 => [
310 'uid' => [$this->mockUser->name],
311 'cn' => [$this->mockUser->name],
312 'dn' => ['dc=test' . config('services.ldap.base_dn')],
313 'mail' => [$this->mockUser->email],
316 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
320 $this->mockUserLogin()->seePageIs('/');
322 $user = User::where('email', $this->mockUser->email)->first();
323 $this->seeInDatabase('role_user', [
324 'user_id' => $user->id,
325 'role_id' => $roleToReceive->id
327 $this->dontSeeInDatabase('role_user', [
328 'user_id' => $user->id,
329 'role_id' => $existingRole->id
333 public function test_external_auth_id_visible_in_roles_page_when_ldap_active()
335 $role = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']);
336 $this->asAdmin()->visit('/settings/roles/' . $role->id)
340 public function test_login_maps_roles_using_external_auth_ids_if_set()
342 $roleToReceive = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']);
343 $roleToNotReceive = factory(Role::class)->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']);
346 'services.ldap.user_to_groups' => true,
347 'services.ldap.group_attribute' => 'memberOf',
348 'services.ldap.remove_from_groups' => true,
351 $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
352 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3)
353 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
354 ->andReturn(['count' => 1, 0 => [
355 'uid' => [$this->mockUser->name],
356 'cn' => [$this->mockUser->name],
357 'dn' => ['dc=test' . config('services.ldap.base_dn')],
358 'mail' => [$this->mockUser->email],
361 0 => "cn=ex-auth-a,ou=groups,dc=example,dc=com",
365 $this->mockUserLogin()->seePageIs('/');
367 $user = User::where('email', $this->mockUser->email)->first();
368 $this->seeInDatabase('role_user', [
369 'user_id' => $user->id,
370 'role_id' => $roleToReceive->id
372 $this->dontSeeInDatabase('role_user', [
373 'user_id' => $user->id,
374 'role_id' => $roleToNotReceive->id
378 public function test_login_group_mapping_does_not_conflict_with_default_role()
380 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
381 $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']);
382 $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save();
384 setting()->put('registration-role', $roleToReceive->id);
387 'services.ldap.user_to_groups' => true,
388 'services.ldap.group_attribute' => 'memberOf',
389 'services.ldap.remove_from_groups' => true,
392 $this->commonLdapMocks(1, 1, 4, 5, 4, 6);
393 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
394 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
395 ->andReturn(['count' => 1, 0 => [
396 'uid' => [$this->mockUser->name],
397 'cn' => [$this->mockUser->name],
398 'dn' => ['dc=test' . config('services.ldap.base_dn')],
399 'mail' => [$this->mockUser->email],
402 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
403 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com",
407 $this->mockUserLogin()->seePageIs('/');
409 $user = User::where('email', $this->mockUser->email)->first();
410 $this->seeInDatabase('role_user', [
411 'user_id' => $user->id,
412 'role_id' => $roleToReceive->id
414 $this->seeInDatabase('role_user', [
415 'user_id' => $user->id,
416 'role_id' => $roleToReceive2->id
420 public function test_login_uses_specified_display_name_attribute()
423 'services.ldap.display_name_attribute' => 'displayName'
426 $this->commonLdapMocks(1, 1, 2, 4, 2);
427 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
428 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
429 ->andReturn(['count' => 1, 0 => [
430 'uid' => [$this->mockUser->name],
431 'cn' => [$this->mockUser->name],
432 'dn' => ['dc=test' . config('services.ldap.base_dn')],
433 'displayname' => 'displayNameAttribute'
436 $this->mockUserLogin()
437 ->seePageIs('/login')->see('Please enter an email to use for this account.');
439 $this->type($this->mockUser->email, '#email')
442 ->see('displayNameAttribute')
443 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']);
446 public function test_login_uses_default_display_name_attribute_if_specified_not_present()
449 'services.ldap.display_name_attribute' => 'displayName'
452 $this->commonLdapMocks(1, 1, 2, 4, 2);
453 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
454 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
455 ->andReturn(['count' => 1, 0 => [
456 'uid' => [$this->mockUser->name],
457 'cn' => [$this->mockUser->name],
458 'dn' => ['dc=test' . config('services.ldap.base_dn')]
461 $this->mockUserLogin()
462 ->seePageIs('/login')->see('Please enter an email to use for this account.');
464 $this->type($this->mockUser->email, '#email')
467 ->see($this->mockUser->name)
468 ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => $this->mockUser->name]);
471 protected function checkLdapReceivesCorrectDetails($serverString, $expectedHost, $expectedPort)
474 'services.ldap.server' => $serverString
478 $this->commonLdapMocks(0, 1, 1, 2, 1);
479 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)->andReturn(['count' => 1, 0 => [
480 'uid' => [$this->mockUser->name],
481 'cn' => [$this->mockUser->name],
482 'dn' => ['dc=test' . config('services.ldap.base_dn')]
485 $this->mockLdap->shouldReceive('connect')->once()
486 ->with($expectedHost, $expectedPort)->andReturn($this->resourceId);
487 $this->mockUserLogin();
490 public function test_ldap_port_provided_on_host_if_host_is_full_uri()
492 $hostName = 'ldaps://bookstack:8080';
493 $this->checkLdapReceivesCorrectDetails($hostName, $hostName, 389);
496 public function test_ldap_port_parsed_from_server_if_host_is_not_full_uri()
498 $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com:8080', 'ldap.bookstack.com', 8080);
501 public function test_default_ldap_port_used_if_not_in_server_string_and_not_uri()
503 $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com', 'ldap.bookstack.com', 389);
506 public function test_forgot_password_routes_inaccessible()
508 $resp = $this->get('/password/email');
509 $this->assertPermissionError($resp);
511 $resp = $this->post('/password/email');
512 $this->assertPermissionError($resp);
514 $resp = $this->get('/password/reset/abc123');
515 $this->assertPermissionError($resp);
517 $resp = $this->post('/password/reset');
518 $this->assertPermissionError($resp);
521 public function test_user_invite_routes_inaccessible()
523 $resp = $this->get('/register/invite/abc123');
524 $this->assertPermissionError($resp);
526 $resp = $this->post('/register/invite/abc123');
527 $this->assertPermissionError($resp);
530 public function test_user_register_routes_inaccessible()
532 $resp = $this->get('/register');
533 $this->assertPermissionError($resp);
535 $resp = $this->post('/register');
536 $this->assertPermissionError($resp);
539 public function test_dump_user_details_option_works()
541 config()->set(['services.ldap.dump_user_details' => true]);
543 $this->commonLdapMocks(1, 1, 1, 1, 1);
544 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
545 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
546 ->andReturn(['count' => 1, 0 => [
547 'uid' => [$this->mockUser->name],
548 'cn' => [$this->mockUser->name],
549 'dn' => ['dc=test' . config('services.ldap.base_dn')]
552 $this->post('/login', [
553 'username' => $this->mockUser->name,
554 'password' => $this->mockUser->password,
556 $this->seeJsonStructure([
557 'details_from_ldap' => [],
558 'details_bookstack_parsed' => [],
562 public function test_start_tls_called_if_option_set()
564 config()->set(['services.ldap.start_tls' => true]);
565 $this->mockLdap->shouldReceive('startTls')->once()->andReturn(true);
566 $this->runFailedAuthLogin();
569 public function test_connection_fails_if_tls_fails()
571 config()->set(['services.ldap.start_tls' => true]);
572 $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false);
573 $this->commonLdapMocks(1, 1, 0, 0, 0);
574 $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
575 $this->assertResponseStatus(500);
578 public function test_ldap_attributes_can_be_binary_decoded_if_marked()
580 config()->set(['services.ldap.id_attribute' => 'BIN;uid']);
581 $ldapService = app()->make(LdapService::class);
582 $this->commonLdapMocks(1, 1, 1, 1, 1);
583 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
584 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn'])
585 ->andReturn(['count' => 1, 0 => [
586 'uid' => [hex2bin('FFF8F7')],
587 'cn' => [$this->mockUser->name],
588 'dn' => ['dc=test' . config('services.ldap.base_dn')]
591 $details = $ldapService->getUserDetails('test');
592 $this->assertEquals('fff8f7', $details['uid']);
595 public function test_new_ldap_user_login_with_already_used_email_address_shows_error_message_to_user()
597 $this->commonLdapMocks(1, 1, 2, 4, 2);
598 $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
599 ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
600 ->andReturn(['count' => 1, 0 => [
601 'uid' => [$this->mockUser->name],
602 'cn' => [$this->mockUser->name],
603 'dn' => ['dc=test' . config('services.ldap.base_dn')],
605 ]], ['count' => 1, 0 => [
608 'dn' => ['dc=bscott' . config('services.ldap.base_dn')],
613 $this->mockUserLogin()->seePageIs('/');
617 $this->post('/login', ['username' => 'bscott', 'password' => 'pass'])->followRedirects();
619 $this->see('A user with the email
[email protected] already exists but with different credentials');
622 public function test_login_with_email_confirmation_required_maps_groups_but_shows_confirmation_screen()
624 $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']);
625 $user = factory(User::class)->make();
626 setting()->put('registration-confirmation', 'true');
629 'services.ldap.user_to_groups' => true,
630 'services.ldap.group_attribute' => 'memberOf',
631 'services.ldap.remove_from_groups' => true,
634 $this->commonLdapMocks(1, 1, 3, 4, 3, 2);
635 $this->mockLdap->shouldReceive('searchAndGetEntries')
637 ->andReturn(['count' => 1, 0 => [
638 'uid' => [$user->name],
639 'cn' => [$user->name],
640 'dn' => ['dc=test' . config('services.ldap.base_dn')],
641 'mail' => [$user->email],
644 0 => "cn=ldaptester,ou=groups,dc=example,dc=com",
648 $this->mockUserLogin()->seePageIs('/register/confirm');
649 $this->seeInDatabase('users', [
650 'email' => $user->email,
651 'email_confirmed' => false,
654 $user = User::query()->where('email', '=', $user->email)->first();
655 $this->seeInDatabase('role_user', [
656 'user_id' => $user->id,
657 'role_id' => $roleToReceive->id
660 $homePage = $this->get('/');
661 $homePage->assertRedirectedTo('/register/confirm/awaiting');
664 public function test_failed_logins_are_logged_when_message_configured()
666 $log = $this->withTestLogger();
667 config()->set(['logging.failed_login.message' => 'Failed login for %u']);
668 $this->runFailedAuthLogin();
669 $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));