diff --git a/app/Auth/Role.php b/app/Auth/Role.php index dcd960948..2918fce52 100644 --- a/app/Auth/Role.php +++ b/app/Auth/Role.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property string $external_auth_id * @property string $system_name * @property bool $mfa_enforced + * @property Collection $users */ class Role extends Model implements Loggable { diff --git a/app/Exceptions/StoppedAuthenticationException.php b/app/Exceptions/StoppedAuthenticationException.php index ef7f24017..d10a6da5e 100644 --- a/app/Exceptions/StoppedAuthenticationException.php +++ b/app/Exceptions/StoppedAuthenticationException.php @@ -55,7 +55,7 @@ class StoppedAuthenticationException extends \Exception implements Responsable ], 401); } - if (session()->get('sent-email-confirmation') === true) { + if (session()->pull('sent-email-confirmation') === true) { return redirect('/register/confirm'); } diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index 2380aad7b..acf67cb9a 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -3,49 +3,41 @@ namespace Tests\Auth; use BookStack\Auth\Access\Mfa\MfaSession; -use BookStack\Auth\Role; use BookStack\Auth\User; use BookStack\Entities\Models\Page; use BookStack\Notifications\ConfirmEmail; use BookStack\Notifications\ResetPassword; -use BookStack\Settings\SettingService; use DB; -use Hash; use Illuminate\Support\Facades\Notification; -use Illuminate\Support\Str; -use Tests\BrowserKitTest; +use Tests\TestCase; +use Tests\TestResponse; -class AuthTest extends BrowserKitTest +class AuthTest extends TestCase { public function test_auth_working() { - $this->visit('/') - ->seePageIs('/login'); + $this->get('/')->assertRedirect('/login'); } public function test_login() { - $this->login('admin@admin.com', 'password') - ->seePageIs('/'); + $this->login('admin@admin.com', 'password')->assertRedirect('/'); } public function test_public_viewing() { - $settings = app(SettingService::class); - $settings->put('app-public', 'true'); - $this->visit('/') - ->seePageIs('/') - ->see('Log In'); + $this->setSettings(['app-public' => 'true']); + $this->get('/') + ->assertOk() + ->assertSee('Log in'); } public function test_registration_showing() { // Ensure registration form is showing $this->setSettings(['registration-enabled' => 'true']); - $this->visit('/login') - ->see('Sign up') - ->click('Sign up') - ->seePageIs('/register'); + $this->get('/login') + ->assertElementContains('a[href="' . url('/register') . '"]', 'Sign up'); } public function test_normal_registration() @@ -55,15 +47,17 @@ class AuthTest extends BrowserKitTest $user = factory(User::class)->make(); // Test form and ensure user is created - $this->visit('/register') - ->see('Sign Up') - ->type($user->name, '#name') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Create Account') - ->seePageIs('/') - ->see($user->name) - ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email]); + $this->get('/register') + ->assertSee('Sign Up') + ->assertElementContains('form[action="' . url('/register') . '"]', 'Create Account'); + + $resp = $this->post('/register', $user->only('password', 'name', 'email')); + $resp->assertRedirect('/'); + + $resp = $this->get('/'); + $resp->assertOk(); + $resp->assertSee($user->name); + $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email]); } public function test_empty_registration_redirects_back_with_errors() @@ -72,36 +66,33 @@ class AuthTest extends BrowserKitTest $this->setSettings(['registration-enabled' => 'true']); // Test form and ensure user is created - $this->visit('/register') - ->press('Create Account') - ->see('The name field is required') - ->seePageIs('/register'); + $this->get('/register'); + $this->post('/register', [])->assertRedirect('/register'); + $this->get('/register')->assertSee('The name field is required'); } public function test_registration_validation() { $this->setSettings(['registration-enabled' => 'true']); - $this->visit('/register') - ->type('1', '#name') - ->type('1', '#email') - ->type('1', '#password') - ->press('Create Account') - ->see('The name must be at least 2 characters.') - ->see('The email must be a valid email address.') - ->see('The password must be at least 8 characters.') - ->seePageIs('/register'); + $this->get('/register'); + $resp = $this->followingRedirects()->post('/register', [ + 'name' => '1', + 'email' => '1', + 'password' => '1', + ]); + $resp->assertSee('The name must be at least 2 characters.'); + $resp->assertSee('The email must be a valid email address.'); + $resp->assertSee('The password must be at least 8 characters.'); } public function test_sign_up_link_on_login() { - $this->visit('/login') - ->dontSee('Sign up'); + $this->get('/login')->assertDontSee('Sign up'); $this->setSettings(['registration-enabled' => 'true']); - $this->visit('/login') - ->see('Sign up'); + $this->get('/login')->assertSee('Sign up'); } public function test_confirmed_registration() @@ -114,27 +105,24 @@ class AuthTest extends BrowserKitTest $user = factory(User::class)->make(); // Go through registration process - $this->visit('/register') - ->see('Sign Up') - ->type($user->name, '#name') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Create Account') - ->seePageIs('/register/confirm') - ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); + $resp = $this->post('/register', $user->only('name', 'email', 'password')); + $resp->assertRedirect('/register/confirm'); + $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); // Ensure notification sent - $dbUser = User::where('email', '=', $user->email)->first(); + /** @var User $dbUser */ + $dbUser = User::query()->where('email', '=', $user->email)->first(); Notification::assertSentTo($dbUser, ConfirmEmail::class); // Test access and resend confirmation email - $this->login($user->email, $user->password) - ->seePageIs('/register/confirm/awaiting') - ->see('Resend') - ->visit('/books') - ->seePageIs('/login') - ->visit('/register/confirm/awaiting') - ->press('Resend Confirmation Email'); + $resp = $this->login($user->email, $user->password); + $resp->assertRedirect('/register/confirm/awaiting'); + + $resp = $this->get('/register/confirm/awaiting'); + $resp->assertElementContains('form[action="' . url('/register/confirm/resend') . '"]', 'Resend'); + + $this->get('/books')->assertRedirect('/login'); + $this->post('/register/confirm/resend', $user->only('email')); // Get confirmation and confirm notification matches $emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first(); @@ -143,188 +131,69 @@ class AuthTest extends BrowserKitTest }); // Check confirmation email confirmation activation. - $this->visit('/register/confirm/' . $emailConfirmation->token) - ->seePageIs('/') - ->see($user->name) - ->notSeeInDatabase('email_confirmations', ['token' => $emailConfirmation->token]) - ->seeInDatabase('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]); + $this->get('/register/confirm/' . $emailConfirmation->token)->assertRedirect('/'); + $this->get('/')->assertSee($user->name); + $this->assertDatabaseMissing('email_confirmations', ['token' => $emailConfirmation->token]); + $this->assertDatabaseHas('users', ['name' => $dbUser->name, 'email' => $dbUser->email, 'email_confirmed' => true]); } public function test_restricted_registration() { $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'true', 'registration-restrict' => 'example.com']); $user = factory(User::class)->make(); + // Go through registration process - $this->visit('/register') - ->type($user->name, '#name') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Create Account') - ->seePageIs('/register') - ->dontSeeInDatabase('users', ['email' => $user->email]) - ->see('That email domain does not have access to this application'); + $this->post('/register', $user->only('name', 'email', 'password')) + ->assertRedirect('/register'); + $resp = $this->get('/register'); + $resp->assertSee('That email domain does not have access to this application'); + $this->assertDatabaseMissing('users', $user->only('email')); $user->email = 'barry@example.com'; - $this->visit('/register') - ->type($user->name, '#name') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Create Account') - ->seePageIs('/register/confirm') - ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); + $this->post('/register', $user->only('name', 'email', 'password')) + ->assertRedirect('/register/confirm'); + $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); $this->assertNull(auth()->user()); - $this->visit('/')->seePageIs('/login') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Log In') - ->seePageIs('/register/confirm/awaiting') - ->seeText('Email Address Not Confirmed'); + $this->get('/')->assertRedirect('/login'); + $resp = $this->followingRedirects()->post('/login', $user->only('email', 'password')); + $resp->assertSee('Email Address Not Confirmed'); + $this->assertNull(auth()->user()); } public function test_restricted_registration_with_confirmation_disabled() { $this->setSettings(['registration-enabled' => 'true', 'registration-confirmation' => 'false', 'registration-restrict' => 'example.com']); $user = factory(User::class)->make(); + // Go through registration process - $this->visit('/register') - ->type($user->name, '#name') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Create Account') - ->seePageIs('/register') - ->dontSeeInDatabase('users', ['email' => $user->email]) - ->see('That email domain does not have access to this application'); + $this->post('/register', $user->only('name', 'email', 'password')) + ->assertRedirect('/register'); + $this->assertDatabaseMissing('users', $user->only('email')); + $this->get('/register')->assertSee('That email domain does not have access to this application'); $user->email = 'barry@example.com'; - $this->visit('/register') - ->type($user->name, '#name') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Create Account') - ->seePageIs('/register/confirm') - ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); + $this->post('/register', $user->only('name', 'email', 'password')) + ->assertRedirect('/register/confirm'); + $this->assertDatabaseHas('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); $this->assertNull(auth()->user()); - $this->visit('/')->seePageIs('/login') - ->type($user->email, '#email') - ->type($user->password, '#password') - ->press('Log In') - ->seePageIs('/register/confirm/awaiting') - ->seeText('Email Address Not Confirmed'); - } - - public function test_user_creation() - { - /** @var User $user */ - $user = factory(User::class)->make(); - $adminRole = Role::getRole('admin'); - - $this->asAdmin() - ->visit('/settings/users') - ->click('Add New User') - ->type($user->name, '#name') - ->type($user->email, '#email') - ->check("roles[{$adminRole->id}]") - ->type($user->password, '#password') - ->type($user->password, '#password-confirm') - ->press('Save') - ->seePageIs('/settings/users') - ->seeInDatabase('users', $user->only(['name', 'email'])) - ->see($user->name); - - $user->refresh(); - $this->assertStringStartsWith(Str::slug($user->name), $user->slug); - } - - public function test_user_updating() - { - $user = $this->getNormalUser(); - $password = $user->password; - $this->asAdmin() - ->visit('/settings/users') - ->click($user->name) - ->seePageIs('/settings/users/' . $user->id) - ->see($user->email) - ->type('Barry Scott', '#name') - ->press('Save') - ->seePageIs('/settings/users') - ->seeInDatabase('users', ['id' => $user->id, 'name' => 'Barry Scott', 'password' => $password]) - ->notSeeInDatabase('users', ['name' => $user->name]); - - $user->refresh(); - $this->assertStringStartsWith(Str::slug($user->name), $user->slug); - } - - public function test_user_password_update() - { - $user = $this->getNormalUser(); - $userProfilePage = '/settings/users/' . $user->id; - $this->asAdmin() - ->visit($userProfilePage) - ->type('newpassword', '#password') - ->press('Save') - ->seePageIs($userProfilePage) - ->see('Password confirmation required') - - ->type('newpassword', '#password') - ->type('newpassword', '#password-confirm') - ->press('Save') - ->seePageIs('/settings/users'); - - $userPassword = User::find($user->id)->password; - $this->assertTrue(Hash::check('newpassword', $userPassword)); - } - - public function test_user_deletion() - { - $userDetails = factory(User::class)->make(); - $user = $this->getEditor($userDetails->toArray()); - - $this->asAdmin() - ->visit('/settings/users/' . $user->id) - ->click('Delete User') - ->see($user->name) - ->press('Confirm') - ->seePageIs('/settings/users') - ->notSeeInDatabase('users', ['name' => $user->name]); - } - - public function test_user_cannot_be_deleted_if_last_admin() - { - $adminRole = Role::getRole('admin'); - - // Delete all but one admin user if there are more than one - $adminUsers = $adminRole->users; - if (count($adminUsers) > 1) { - foreach ($adminUsers->splice(1) as $user) { - $user->delete(); - } - } - - // Ensure we currently only have 1 admin user - $this->assertEquals(1, $adminRole->users()->count()); - $user = $adminRole->users->first(); - - $this->asAdmin()->visit('/settings/users/' . $user->id) - ->click('Delete User') - ->press('Confirm') - ->seePageIs('/settings/users/' . $user->id) - ->see('You cannot delete the only admin'); + $this->get('/')->assertRedirect('/login'); + $resp = $this->post('/login', $user->only('email', 'password')); + $resp->assertRedirect('/register/confirm/awaiting'); + $this->get('/register/confirm/awaiting')->assertSee('Email Address Not Confirmed'); + $this->assertNull(auth()->user()); } public function test_logout() { - $this->asAdmin() - ->visit('/') - ->seePageIs('/') - ->visit('/logout') - ->visit('/') - ->seePageIs('/login'); + $this->asAdmin()->get('/')->assertOk(); + $this->get('/logout')->assertRedirect('/'); + $this->get('/')->assertRedirect('/login'); } public function test_mfa_session_cleared_on_logout() @@ -335,7 +204,7 @@ class AuthTest extends BrowserKitTest $mfaSession->markVerifiedForUser($user); $this->assertTrue($mfaSession->isVerifiedForUser($user)); - $this->asAdmin()->visit('/logout'); + $this->asAdmin()->get('/logout'); $this->assertFalse($mfaSession->isVerifiedForUser($user)); } @@ -343,69 +212,86 @@ class AuthTest extends BrowserKitTest { Notification::fake(); - $this->visit('/login')->click('Forgot Password?') - ->seePageIs('/password/email') - ->type('admin@admin.com', 'email') - ->press('Send Reset Link') - ->see('A password reset link will be sent to admin@admin.com if that email address is found in the system.'); + $this->get('/login') + ->assertElementContains('a[href="' . url('/password/email') . '"]', 'Forgot Password?'); - $this->seeInDatabase('password_resets', [ + $this->get('/password/email') + ->assertElementContains('form[action="' . url('/password/email') . '"]', 'Send Reset Link'); + + $resp = $this->post('/password/email', [ + 'email' => 'admin@admin.com', + ]); + $resp->assertRedirect('/password/email'); + + $resp = $this->get('/password/email'); + $resp->assertSee('A password reset link will be sent to admin@admin.com if that email address is found in the system.'); + + $this->assertDatabaseHas('password_resets', [ 'email' => 'admin@admin.com', ]); - $user = User::where('email', '=', 'admin@admin.com')->first(); + /** @var User $user */ + $user = User::query()->where('email', '=', 'admin@admin.com')->first(); Notification::assertSentTo($user, ResetPassword::class); $n = Notification::sent($user, ResetPassword::class); - $this->visit('/password/reset/' . $n->first()->token) - ->see('Reset Password') - ->submitForm('Reset Password', [ - 'email' => 'admin@admin.com', - 'password' => 'randompass', - 'password_confirmation' => 'randompass', - ])->seePageIs('/') - ->see('Your password has been successfully reset'); + $this->get('/password/reset/' . $n->first()->token) + ->assertOk() + ->assertSee('Reset Password'); + + $resp = $this->post('/password/reset', [ + 'email' => 'admin@admin.com', + 'password' => 'randompass', + 'password_confirmation' => 'randompass', + 'token' => $n->first()->token + ]); + $resp->assertRedirect('/'); + + $this->get('/')->assertSee('Your password has been successfully reset'); } public function test_reset_password_flow_shows_success_message_even_if_wrong_password_to_prevent_user_discovery() { - $this->visit('/login')->click('Forgot Password?') - ->seePageIs('/password/email') - ->type('barry@admin.com', 'email') - ->press('Send Reset Link') - ->see('A password reset link will be sent to barry@admin.com if that email address is found in the system.') - ->dontSee('We can\'t find a user'); + $this->get('/password/email'); + $resp = $this->followingRedirects()->post('/password/email', [ + 'email' => 'barry@admin.com', + ]); + $resp->assertSee('A password reset link will be sent to barry@admin.com if that email address is found in the system.'); + $resp->assertDontSee('We can\'t find a user'); - $this->visit('/password/reset/arandometokenvalue') - ->see('Reset Password') - ->submitForm('Reset Password', [ - 'email' => 'barry@admin.com', - 'password' => 'randompass', - 'password_confirmation' => 'randompass', - ])->followRedirects() - ->seePageIs('/password/reset/arandometokenvalue') - ->dontSee('We can\'t find a user') - ->see('The password reset token is invalid for this email address.'); + + $this->get('/password/reset/arandometokenvalue')->assertSee('Reset Password'); + $resp = $this->post('/password/reset', [ + 'email' => 'barry@admin.com', + 'password' => 'randompass', + 'password_confirmation' => 'randompass', + 'token' => 'arandometokenvalue' + ]); + $resp->assertRedirect('/password/reset/arandometokenvalue'); + + $this->get('/password/reset/arandometokenvalue') + ->assertDontSee('We can\'t find a user') + ->assertSee('The password reset token is invalid for this email address.'); } public function test_reset_password_page_shows_sign_links() { $this->setSettings(['registration-enabled' => 'true']); - $this->visit('/password/email') - ->seeLink('Log in') - ->seeLink('Sign up'); + $this->get('/password/email') + ->assertElementContains('a', 'Log in') + ->assertElementContains('a', 'Sign up'); } public function test_login_redirects_to_initially_requested_url_correctly() { config()->set('app.url', 'http://localhost'); + /** @var Page $page */ $page = Page::query()->first(); - $this->visit($page->getUrl()) - ->seePageUrlIs(url('/login')); + $this->get($page->getUrl())->assertRedirect(url('/login')); $this->login('admin@admin.com', 'password') - ->seePageUrlIs($page->getUrl()); + ->assertRedirect($page->getUrl()); } public function test_login_intended_redirect_does_not_redirect_to_external_pages() @@ -416,15 +302,15 @@ class AuthTest extends BrowserKitTest $this->get('/login', ['referer' => 'https://example.com']); $login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); - $login->assertRedirectedTo('http://localhost'); + $login->assertRedirect('http://localhost'); } public function test_login_intended_redirect_does_not_factor_mfa_routes() { - $this->get('/books')->assertRedirectedTo('/login'); - $this->get('/mfa/setup')->assertRedirectedTo('/login'); + $this->get('/books')->assertRedirect('/login'); + $this->get('/mfa/setup')->assertRedirect('/login'); $login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); - $login->assertRedirectedTo('/books'); + $login->assertRedirect('/books'); } public function test_login_authenticates_admins_on_all_guards() @@ -469,20 +355,15 @@ class AuthTest extends BrowserKitTest auth()->login($user); $this->assertTrue(auth()->check()); - $this->get('/books'); - $this->assertRedirectedTo('/'); - + $this->get('/books')->assertRedirect('/'); $this->assertFalse(auth()->check()); } /** * Perform a login. */ - protected function login(string $email, string $password): AuthTest + protected function login(string $email, string $password): TestResponse { - return $this->visit('/login') - ->type($email, '#email') - ->type($password, '#password') - ->press('Log In'); + return $this->post('/login', compact('email', 'password')); } } diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index cd0d244c9..77bd5076b 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -89,7 +89,7 @@ trait SharedTestHelpers /** * Get a user that's not a system user such as the guest user. */ - public function getNormalUser() + public function getNormalUser(): User { return User::query()->where('system_name', '=', null)->get()->last(); } diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index b7331d870..f52a78a13 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -3,12 +3,114 @@ namespace Tests\User; use BookStack\Actions\ActivityType; +use BookStack\Auth\Role; use BookStack\Auth\User; use BookStack\Entities\Models\Page; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Str; use Tests\TestCase; class UserManagementTest extends TestCase { + + public function test_user_creation() + { + /** @var User $user */ + $user = factory(User::class)->make(); + $adminRole = Role::getRole('admin'); + + $resp = $this->asAdmin()->get('/settings/users'); + $resp->assertElementContains('a[href="' . url('/settings/users/create') . '"]', 'Add New User'); + + $this->get('/settings/users/create') + ->assertElementContains('form[action="' . url('/settings/users/create') . '"]', 'Save'); + + $resp = $this->post('/settings/users/create', [ + 'name' => $user->name, + 'email' => $user->email, + 'password' => $user->password, + 'password-confirm' => $user->password, + 'roles[' . $adminRole->id . ']' => 'true', + ]); + $resp->assertRedirect('/settings/users'); + + $resp = $this->get('/settings/users'); + $resp->assertSee($user->name); + + $this->assertDatabaseHas('users', $user->only('name', 'email')); + + $user->refresh(); + $this->assertStringStartsWith(Str::slug($user->name), $user->slug); + } + + public function test_user_updating() + { + $user = $this->getNormalUser(); + $password = $user->password; + + + $resp = $this->asAdmin()->get('/settings/users/' . $user->id); + $resp->assertSee($user->email); + + $this->put($user->getEditUrl(), [ + 'name' => 'Barry Scott' + ])->assertRedirect('/settings/users'); + + $this->assertDatabaseHas('users', ['id' => $user->id, 'name' => 'Barry Scott', 'password' => $password]); + $this->assertDatabaseMissing('users', ['name' => $user->name]); + + $user->refresh(); + $this->assertStringStartsWith(Str::slug($user->name), $user->slug); + } + + public function test_user_password_update() + { + $user = $this->getNormalUser(); + $userProfilePage = '/settings/users/' . $user->id; + + $this->asAdmin()->get($userProfilePage); + $this->put($userProfilePage, [ + 'password' => 'newpassword' + ])->assertRedirect($userProfilePage); + + $this->get($userProfilePage)->assertSee('Password confirmation required'); + + $this->put($userProfilePage, [ + 'password' => 'newpassword', + 'password-confirm' => 'newpassword', + ])->assertRedirect('/settings/users'); + + $userPassword = User::query()->find($user->id)->password; + $this->assertTrue(Hash::check('newpassword', $userPassword)); + } + + public function test_user_cannot_be_deleted_if_last_admin() + { + $adminRole = Role::getRole('admin'); + + // Delete all but one admin user if there are more than one + $adminUsers = $adminRole->users; + if (count($adminUsers) > 1) { + /** @var User $user */ + foreach ($adminUsers->splice(1) as $user) { + $user->delete(); + } + } + + // Ensure we currently only have 1 admin user + $this->assertEquals(1, $adminRole->users()->count()); + /** @var User $user */ + $user = $adminRole->users->first(); + + $resp = $this->asAdmin()->delete('/settings/users/' . $user->id); + $resp->assertRedirect('/settings/users/' . $user->id); + + $resp = $this->get('/settings/users/' . $user->id); + $resp->assertSee('You cannot delete the only admin'); + + $this->assertDatabaseHas('users', ['id' => $user->id]); + } + public function test_delete() { $editor = $this->getEditor();