diff --git a/.env.example b/.env.example index f5e81277c..47f2367b0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,11 @@ +# This file, when named as ".env" in the root of your BookStack install +# folder, is used for the core configuration of the application. +# By default this file contains the most common required options but +# a full list of options can be found in the '.env.example.complete' file. + +# NOTE: If any of your values contain a space or a hash you will need to +# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer") + # Application key # Used for encryption where needed. # Run `php artisan key:generate` to generate a valid key. @@ -5,7 +13,7 @@ APP_KEY=SomeRandomString # Application URL # Remove the hash below and set a URL if using BookStack behind -# a proxy, if using a third-party authentication option. +# a proxy or if using a third-party authentication option. # This must be the root URL that you want to host BookStack on. # All URL's in BookStack will be generated using this value. #APP_URL=https://example.com @@ -25,11 +33,10 @@ MAIL_FROM_NAME=BookStack MAIL_FROM=bookstack@example.com # SMTP mail options +# These settings can be checked using the "Send a Test Email" +# feature found in the "Settings > Maintenance" area of the system. MAIL_HOST=localhost MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_ENCRYPTION=null - - -# A full list of options can be found in the '.env.example.complete' file. \ No newline at end of file +MAIL_ENCRYPTION=null \ No newline at end of file diff --git a/.env.example.complete b/.env.example.complete index 86a7351c2..19643a49f 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -238,7 +238,10 @@ DISABLE_EXTERNAL_SERVICES=false # Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon AVATAR_URL= -# Enable Draw.io integration +# Enable diagrams.net integration +# Can simply be true/false to enable/disable the integration. +# Alternatively, It can be URL to the diagrams.net instance you want to use. +# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1 DRAWIO=true # Default item listing view @@ -252,6 +255,14 @@ APP_VIEWS_BOOKSHELVES=grid # If set to 'false' a limit will not be enforced. REVISION_LIMIT=50 +# Recycle Bin Lifetime +# The number of days that content will remain in the recycle bin before +# being considered for auto-removal. It is not a guarantee that content will +# be removed after this time. +# Set to 0 for no recycle bin functionality. +# Set to -1 for unlimited recycle bin lifetime. +RECYCLE_BIN_LIFETIME=30 + # Allow

Hello

"; + $page->save(); + + $resp = $this->getJson($this->baseEndpoint . "/{$page->id}"); + $html = $resp->json('html'); + $this->assertStringNotContainsString('script', $html); + $this->assertStringContainsString('Hello', $html); + $this->assertStringContainsString('testing', $html); + } + + public function test_update_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $details = [ + 'name' => 'My updated API page', + 'html' => '

A page created via the API

', + 'tags' => [ + [ + 'name' => 'freshtag', + 'value' => 'freshtagval', + ] + ], + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $page->refresh(); + + $resp->assertStatus(200); + unset($details['html']); + $resp->assertJson(array_merge($details, [ + 'id' => $page->id, 'slug' => $page->slug, 'book_id' => $page->book_id + ])); + $this->assertActivityExists('page_update', $page); + } + + public function test_providing_new_chapter_id_on_update_will_move_page() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first(); + $details = [ + 'name' => 'My updated API page', + 'chapter_id' => $chapter->id, + 'html' => '

A page created via the API

', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $resp->assertStatus(200); + $resp->assertJson([ + 'chapter_id' => $chapter->id, + 'book_id' => $chapter->book_id, + ]); + } + + public function test_providing_move_via_update_requires_page_create_permission_on_new_parent() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first(); + $this->setEntityRestrictions($chapter, ['view'], [$this->getEditor()->roles()->first()]); + $details = [ + 'name' => 'My updated API page', + 'chapter_id' => $chapter->id, + 'html' => '

A page created via the API

', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $resp->assertStatus(403); + } + + public function test_delete_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + $resp = $this->deleteJson($this->baseEndpoint . "/{$page->id}"); + + $resp->assertStatus(204); + $this->assertActivityExists('page_delete', $page); + } + + public function test_export_html_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + } + + public function test_export_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + } + + public function test_export_pdf_endpoint() + { + $this->actingAsApiEditor(); + $page = Page::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + } +} \ No newline at end of file diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php new file mode 100644 index 000000000..4c5600d15 --- /dev/null +++ b/tests/Api/ShelvesApiTest.php @@ -0,0 +1,136 @@ +actingAsApiEditor(); + $firstBookshelf = Bookshelf::query()->orderBy('id', 'asc')->first(); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $firstBookshelf->id, + 'name' => $firstBookshelf->name, + 'slug' => $firstBookshelf->slug, + ] + ]]); + } + + public function test_create_endpoint() + { + $this->actingAsApiEditor(); + $books = Book::query()->take(2)->get(); + + $details = [ + 'name' => 'My API shelf', + 'description' => 'A shelf created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['books' => [$books[0]->id, $books[1]->id]])); + $resp->assertStatus(200); + $newItem = Bookshelf::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug])); + $this->assertActivityExists('bookshelf_create', $newItem); + foreach ($books as $index => $book) { + $this->assertDatabaseHas('bookshelves_books', [ + 'bookshelf_id' => $newItem->id, + 'book_id' => $book->id, + 'order' => $index, + ]); + } + } + + public function test_shelf_name_needed_to_create() + { + $this->actingAsApiEditor(); + $details = [ + 'description' => 'A shelf created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson([ + "error" => [ + "message" => "The given data was invalid.", + "validation" => [ + "name" => ["The name field is required."] + ], + "code" => 422, + ], + ]); + } + + public function test_read_endpoint() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + + $resp = $this->getJson($this->baseEndpoint . "/{$shelf->id}"); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $shelf->id, + 'slug' => $shelf->slug, + 'created_by' => [ + 'name' => $shelf->createdBy->name, + ], + 'updated_by' => [ + 'name' => $shelf->createdBy->name, + ] + ]); + } + + public function test_update_endpoint() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + $details = [ + 'name' => 'My updated API shelf', + 'description' => 'A shelf created via the API', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); + $shelf->refresh(); + + $resp->assertStatus(200); + $resp->assertJson(array_merge($details, ['id' => $shelf->id, 'slug' => $shelf->slug])); + $this->assertActivityExists('bookshelf_update', $shelf); + } + + public function test_update_only_assigns_books_if_param_provided() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + $this->assertTrue($shelf->books()->count() > 0); + $details = [ + 'name' => 'My updated API shelf', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); + $resp->assertStatus(200); + $this->assertTrue($shelf->books()->count() > 0); + + $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", ['books' => []]); + $resp->assertStatus(200); + $this->assertTrue($shelf->books()->count() === 0); + } + + public function test_delete_endpoint() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + $resp = $this->deleteJson($this->baseEndpoint . "/{$shelf->id}"); + + $resp->assertStatus(204); + $this->assertActivityExists('bookshelf_delete'); + } +} \ No newline at end of file diff --git a/tests/TestsApi.php b/tests/Api/TestsApi.php similarity index 67% rename from tests/TestsApi.php rename to tests/Api/TestsApi.php index 0bb10a4cc..1ad4d14b6 100644 --- a/tests/TestsApi.php +++ b/tests/Api/TestsApi.php @@ -1,6 +1,4 @@ - ["code" => $code, "message" => $message]]; } + /** + * Format the given (field_name => ["messages"]) array + * into a standard validation response format. + */ + protected function validationResponse(array $messages): array + { + $err = $this->errorResponse("The given data was invalid.", 422); + $err['error']['validation'] = $messages; + return $err; + } /** * Get an approved API auth header. */ diff --git a/tests/AuditLogTest.php b/tests/AuditLogTest.php new file mode 100644 index 000000000..3dc6fd7c2 --- /dev/null +++ b/tests/AuditLogTest.php @@ -0,0 +1,120 @@ +activityService = app(ActivityService::class); + } + + public function test_only_accessible_with_right_permissions() + { + $viewer = $this->getViewer(); + $this->actingAs($viewer); + + $resp = $this->get('/settings/audit'); + $this->assertPermissionError($resp); + + $this->giveUserPermissions($viewer, ['settings-manage']); + $resp = $this->get('/settings/audit'); + $this->assertPermissionError($resp); + + $this->giveUserPermissions($viewer, ['users-manage']); + $resp = $this->get('/settings/audit'); + $resp->assertStatus(200); + $resp->assertSeeText('Audit Log'); + } + + public function test_shows_activity() + { + $admin = $this->getAdmin(); + $this->actingAs($admin); + $page = Page::query()->first(); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + $activity = Activity::query()->orderBy('id', 'desc')->first(); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText($page->name); + $resp->assertSeeText('page_create'); + $resp->assertSeeText($activity->created_at->toDateTimeString()); + $resp->assertElementContains('.table-user-item', $admin->name); + } + + public function test_shows_name_for_deleted_items() + { + $this->actingAs( $this->getAdmin()); + $page = Page::query()->first(); + $pageName = $page->name; + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + + app(PageRepo::class)->destroy($page); + app(TrashCan::class)->empty(); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText('Deleted Item'); + $resp->assertSeeText('Name: ' . $pageName); + } + + public function test_shows_activity_for_deleted_users() + { + $viewer = $this->getViewer(); + $this->actingAs($viewer); + $page = Page::query()->first(); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + + $this->actingAs($this->getAdmin()); + app(UserRepo::class)->destroy($viewer); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText("[ID: {$viewer->id}] Deleted User"); + } + + public function test_filters_by_key() + { + $this->actingAs($this->getAdmin()); + $page = Page::query()->first(); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + + $resp = $this->get('settings/audit'); + $resp->assertSeeText($page->name); + + $resp = $this->get('settings/audit?event=page_delete'); + $resp->assertDontSeeText($page->name); + } + + public function test_date_filters() + { + $this->actingAs($this->getAdmin()); + $page = Page::query()->first(); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + + $yesterday = (Carbon::now()->subDay()->format('Y-m-d')); + $tomorrow = (Carbon::now()->addDay()->format('Y-m-d')); + + $resp = $this->get('settings/audit?date_from=' . $yesterday); + $resp->assertSeeText($page->name); + + $resp = $this->get('settings/audit?date_from=' . $tomorrow); + $resp->assertDontSeeText($page->name); + + $resp = $this->get('settings/audit?date_to=' . $tomorrow); + $resp->assertSeeText($page->name); + + $resp = $this->get('settings/audit?date_to=' . $yesterday); + $resp->assertDontSeeText($page->name); + } + +} \ No newline at end of file diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index eb83faded..a0de7f803 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -1,10 +1,15 @@ -press('Resend Confirmation Email'); // Get confirmation and confirm notification matches - $emailConfirmation = \DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first(); + $emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first(); Notification::assertSentTo($dbUser, ConfirmEmail::class, function($notification, $channels) use ($emailConfirmation) { return $notification->token === $emailConfirmation->token; }); @@ -165,6 +170,11 @@ class AuthTest extends BrowserKitTest ->seePageIs('/register/confirm') ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); + $this->visit('/') + ->seePageIs('/register/confirm/awaiting'); + + auth()->logout(); + $this->visit('/')->seePageIs('/login') ->type($user->email, '#email') ->type($user->password, '#password') @@ -197,6 +207,10 @@ class AuthTest extends BrowserKitTest ->seePageIs('/register/confirm') ->seeInDatabase('users', ['name' => $user->name, 'email' => $user->email, 'email_confirmed' => false]); + $this->visit('/') + ->seePageIs('/register/confirm/awaiting'); + + auth()->logout(); $this->visit('/')->seePageIs('/login') ->type($user->email, '#email') ->type($user->password, '#password') @@ -208,13 +222,14 @@ class AuthTest extends BrowserKitTest public function test_user_creation() { $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[admin]') + ->check("roles[{$adminRole->id}]") ->type($user->password, '#password') ->type($user->password, '#password-confirm') ->press('Save') @@ -256,7 +271,7 @@ class AuthTest extends BrowserKitTest ->seePageIs('/settings/users'); $userPassword = User::find($user->id)->password; - $this->assertTrue(\Hash::check('newpassword', $userPassword)); + $this->assertTrue(Hash::check('newpassword', $userPassword)); } public function test_user_deletion() @@ -275,7 +290,7 @@ class AuthTest extends BrowserKitTest public function test_user_cannot_be_deleted_if_last_admin() { - $adminRole = \BookStack\Auth\Role::getRole('admin'); + $adminRole = Role::getRole('admin'); // Delete all but one admin user if there are more than one $adminUsers = $adminRole->users; @@ -308,14 +323,13 @@ class AuthTest extends BrowserKitTest public function test_reset_password_flow() { - 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 has been sent to admin@admin.com'); + ->see('A password reset link will be sent to admin@admin.com if that email address is found in the system.'); $this->seeInDatabase('password_resets', [ 'email' => 'admin@admin.com' @@ -323,8 +337,8 @@ class AuthTest extends BrowserKitTest $user = User::where('email', '=', 'admin@admin.com')->first(); - Notification::assertSentTo($user, \BookStack\Notifications\ResetPassword::class); - $n = Notification::sent($user, \BookStack\Notifications\ResetPassword::class); + Notification::assertSentTo($user, ResetPassword::class); + $n = Notification::sent($user, ResetPassword::class); $this->visit('/password/reset/' . $n->first()->token) ->see('Reset Password') @@ -336,6 +350,28 @@ class AuthTest extends BrowserKitTest ->see('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->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.'); + } + public function test_reset_password_page_shows_sign_links() { $this->setSettings(['registration-enabled' => 'true']); @@ -355,13 +391,53 @@ class AuthTest extends BrowserKitTest ->seePageUrlIs($page->getUrl()); } + public function test_login_intended_redirect_does_not_redirect_to_external_pages() + { + config()->set('app.url', 'http://localhost'); + $this->setSettings(['app-public' => true]); + + $this->get('/login', ['referer' => 'https://example.com']); + $login = $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); + + $login->assertRedirectedTo('http://localhost'); + } + + public function test_login_authenticates_admins_on_all_guards() + { + $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); + $this->assertTrue(auth()->check()); + $this->assertTrue(auth('ldap')->check()); + $this->assertTrue(auth('saml2')->check()); + } + + public function test_login_authenticates_nonadmins_on_default_guard_only() + { + $editor = $this->getEditor(); + $editor->password = bcrypt('password'); + $editor->save(); + + $this->post('/login', ['email' => $editor->email, 'password' => 'password']); + $this->assertTrue(auth()->check()); + $this->assertFalse(auth('ldap')->check()); + $this->assertFalse(auth('saml2')->check()); + } + + public function test_failed_logins_are_logged_when_message_configured() + { + $log = $this->withTestLogger(); + config()->set(['logging.failed_login.message' => 'Failed login for %u']); + + $this->post('/login', ['email' => 'admin@example.com', 'password' => 'cattreedog']); + $this->assertTrue($log->hasWarningThatContains('Failed login for admin@example.com')); + + $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); + $this->assertFalse($log->hasWarningThatContains('Failed login for admin@admin.com')); + } + /** * Perform a login - * @param string $email - * @param string $password - * @return $this */ - protected function login($email, $password) + protected function login(string $email, string $password): AuthTest { return $this->visit('/login') ->type($email, '#email') diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 324e3041f..3cb39ca2c 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -1,10 +1,11 @@ -press('Log In'); } + /** + * Set LDAP method mocks for things we commonly call without altering. + */ + protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0) + { + $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId); + $this->mockLdap->shouldReceive('setVersion')->times($versions); + $this->mockLdap->shouldReceive('setOption')->times($options); + $this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true); + $this->mockEscapes($escapes); + $this->mockExplodes($explodes); + } + public function test_login() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -74,8 +86,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login')->see('Please enter an email to use for this account.'); @@ -93,9 +103,7 @@ class LdapTest extends BrowserKitTest 'registration-restrict' => 'testing.com' ]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -103,8 +111,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login') @@ -121,10 +127,9 @@ class LdapTest extends BrowserKitTest public function test_login_works_when_no_uid_provided_by_ldap_server() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); - $this->mockLdap->shouldReceive('setOption')->times(1); + + $this->commonLdapMocks(1, 1, 1, 2, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -132,8 +137,6 @@ class LdapTest extends BrowserKitTest 'dn' => $ldapDn, 'mail' => [$this->mockUser->email] ]]); - $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true); - $this->mockEscapes(1); $this->mockUserLogin() ->seePageIs('/') @@ -144,10 +147,9 @@ class LdapTest extends BrowserKitTest public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly() { config()->set(['services.ldap.id_attribute' => 'my_custom_id']); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); + + $this->commonLdapMocks(1, 1, 1, 2, 1); $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); - $this->mockLdap->shouldReceive('setOption')->times(1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -158,9 +160,6 @@ class LdapTest extends BrowserKitTest ]]); - $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true); - $this->mockEscapes(1); - $this->mockUserLogin() ->seePageIs('/') ->see($this->mockUser->name) @@ -169,9 +168,7 @@ class LdapTest extends BrowserKitTest public function test_initial_incorrect_credentials() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 0, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -180,7 +177,6 @@ class LdapTest extends BrowserKitTest 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false); - $this->mockEscapes(1); $this->mockUserLogin() ->seePageIs('/login')->see('These credentials do not match our records.') @@ -189,14 +185,10 @@ class LdapTest extends BrowserKitTest public function test_login_not_found_username() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 1, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 0]); - $this->mockLdap->shouldReceive('bind')->times(1)->andReturn(true, false); - $this->mockEscapes(1); $this->mockUserLogin() ->seePageIs('/login')->see('These credentials do not match our records.') @@ -245,9 +237,9 @@ class LdapTest extends BrowserKitTest public function test_login_maps_roles_and_retains_existing_roles() { - $roleToReceive = factory(Role::class)->create(['name' => 'ldaptester', 'display_name' => 'LdapTester']); - $roleToReceive2 = factory(Role::class)->create(['name' => 'ldaptester-second', 'display_name' => 'LdapTester Second']); - $existingRole = factory(Role::class)->create(['name' => 'ldaptester-existing']); + $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']); + $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']); + $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']); $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save(); $this->mockUser->attachRole($existingRole); @@ -256,9 +248,8 @@ class LdapTest extends BrowserKitTest 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => false, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(4); + + $this->commonLdapMocks(1, 1, 4, 5, 4, 6); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -272,9 +263,6 @@ class LdapTest extends BrowserKitTest 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(5)->andReturn(true); - $this->mockEscapes(4); - $this->mockExplodes(6); $this->mockUserLogin()->seePageIs('/'); @@ -295,8 +283,8 @@ class LdapTest extends BrowserKitTest public function test_login_maps_roles_and_removes_old_roles_if_set() { - $roleToReceive = factory(Role::class)->create(['name' => 'ldaptester', 'display_name' => 'LdapTester']); - $existingRole = factory(Role::class)->create(['name' => 'ldaptester-existing']); + $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']); + $existingRole = factory(Role::class)->create(['display_name' => 'ldaptester-existing']); $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save(); $this->mockUser->attachRole($existingRole); @@ -305,9 +293,8 @@ class LdapTest extends BrowserKitTest 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => true, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(3); + + $this->commonLdapMocks(1, 1, 3, 4, 3, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -320,9 +307,6 @@ class LdapTest extends BrowserKitTest 0 => "cn=ldaptester,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(3); - $this->mockExplodes(2); $this->mockUserLogin()->seePageIs('/'); @@ -339,24 +323,23 @@ class LdapTest extends BrowserKitTest public function test_external_auth_id_visible_in_roles_page_when_ldap_active() { - $role = factory(Role::class)->create(['name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']); + $role = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'ex-auth-a, test-second-param']); $this->asAdmin()->visit('/settings/roles/' . $role->id) ->see('ex-auth-a'); } public function test_login_maps_roles_using_external_auth_ids_if_set() { - $roleToReceive = factory(Role::class)->create(['name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']); - $roleToNotReceive = factory(Role::class)->create(['name' => 'ldaptester-not-receive', 'display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']); + $roleToReceive = factory(Role::class)->create(['display_name' => 'ldaptester', 'external_auth_id' => 'test-second-param, ex-auth-a']); + $roleToNotReceive = factory(Role::class)->create(['display_name' => 'ex-auth-a', 'external_auth_id' => 'test-second-param']); app('config')->set([ 'services.ldap.user_to_groups' => true, 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => true, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(3); + + $this->commonLdapMocks(1, 1, 3, 4, 3, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -369,9 +352,6 @@ class LdapTest extends BrowserKitTest 0 => "cn=ex-auth-a,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(3); - $this->mockExplodes(2); $this->mockUserLogin()->seePageIs('/'); @@ -388,8 +368,8 @@ class LdapTest extends BrowserKitTest public function test_login_group_mapping_does_not_conflict_with_default_role() { - $roleToReceive = factory(Role::class)->create(['name' => 'ldaptester', 'display_name' => 'LdapTester']); - $roleToReceive2 = factory(Role::class)->create(['name' => 'ldaptester-second', 'display_name' => 'LdapTester Second']); + $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']); + $roleToReceive2 = factory(Role::class)->create(['display_name' => 'LdapTester Second']); $this->mockUser->forceFill(['external_auth_id' => $this->mockUser->name])->save(); setting()->put('registration-role', $roleToReceive->id); @@ -399,9 +379,8 @@ class LdapTest extends BrowserKitTest 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => true, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(4); + + $this->commonLdapMocks(1, 1, 4, 5, 4, 6); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -415,9 +394,6 @@ class LdapTest extends BrowserKitTest 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(5)->andReturn(true); - $this->mockEscapes(4); - $this->mockExplodes(6); $this->mockUserLogin()->seePageIs('/'); @@ -438,9 +414,7 @@ class LdapTest extends BrowserKitTest 'services.ldap.display_name_attribute' => 'displayName' ]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -449,8 +423,6 @@ class LdapTest extends BrowserKitTest 'dn' => ['dc=test' . config('services.ldap.base_dn')], 'displayname' => 'displayNameAttribute' ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login')->see('Please enter an email to use for this account.'); @@ -468,9 +440,7 @@ class LdapTest extends BrowserKitTest 'services.ldap.display_name_attribute' => 'displayName' ]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -478,8 +448,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login')->see('Please enter an email to use for this account.'); @@ -498,15 +466,12 @@ class LdapTest extends BrowserKitTest ]); // Standard mocks - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(0, 1, 1, 2, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true); - $this->mockEscapes(1); $this->mockLdap->shouldReceive('connect')->once() ->with($expectedHost, $expectedPort)->andReturn($this->resourceId); @@ -566,9 +531,7 @@ class LdapTest extends BrowserKitTest { config()->set(['services.ldap.dump_user_details' => true]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 1, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -576,8 +539,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(1)->andReturn(true); - $this->mockEscapes(1); $this->post('/login', [ 'username' => $this->mockUser->name, @@ -593,10 +554,7 @@ class LdapTest extends BrowserKitTest { config()->set(['services.ldap.id_attribute' => 'BIN;uid']); $ldapService = app()->make(LdapService::class); - - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 1, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn']) ->andReturn(['count' => 1, 0 => [ @@ -604,10 +562,90 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(1)->andReturn(true); - $this->mockEscapes(1); $details = $ldapService->getUserDetails('test'); $this->assertEquals('fff8f7', $details['uid']); } + + public function test_new_ldap_user_login_with_already_used_email_address_shows_error_message_to_user() + { + $this->commonLdapMocks(1, 1, 2, 4, 2); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'mail' => 'tester@example.com', + ]], ['count' => 1, 0 => [ + 'uid' => ['Barry'], + 'cn' => ['Scott'], + 'dn' => ['dc=bscott' . config('services.ldap.base_dn')], + 'mail' => 'tester@example.com', + ]]); + + // First user login + $this->mockUserLogin()->seePageIs('/'); + + // Second user login + auth()->logout(); + $this->post('/login', ['username' => 'bscott', 'password' => 'pass'])->followRedirects(); + + $this->see('A user with the email tester@example.com already exists but with different credentials'); + } + + public function test_login_with_email_confirmation_required_maps_groups_but_shows_confirmation_screen() + { + $roleToReceive = factory(Role::class)->create(['display_name' => 'LdapTester']); + $user = factory(User::class)->make(); + setting()->put('registration-confirmation', 'true'); + + app('config')->set([ + 'services.ldap.user_to_groups' => true, + 'services.ldap.group_attribute' => 'memberOf', + 'services.ldap.remove_from_groups' => true, + ]); + + $this->commonLdapMocks(1, 1, 3, 4, 3, 2); + $this->mockLdap->shouldReceive('searchAndGetEntries') + ->times(3) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$user->name], + 'cn' => [$user->name], + 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'mail' => [$user->email], + 'memberof' => [ + 'count' => 1, + 0 => "cn=ldaptester,ou=groups,dc=example,dc=com", + ] + ]]); + + $this->mockUserLogin()->seePageIs('/register/confirm'); + $this->seeInDatabase('users', [ + 'email' => $user->email, + 'email_confirmed' => false, + ]); + + $user = User::query()->where('email', '=', $user->email)->first(); + $this->seeInDatabase('role_user', [ + 'user_id' => $user->id, + 'role_id' => $roleToReceive->id + ]); + + $homePage = $this->get('/'); + $homePage->assertRedirectedTo('/register/confirm/awaiting'); + } + + public function test_failed_logins_are_logged_when_message_configured() + { + $log = $this->withTestLogger(); + config()->set(['logging.failed_login.message' => 'Failed login for %u']); + + $this->commonLdapMocks(1, 1, 1, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->andReturn(['count' => 0]); + + $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']); + $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins')); + } } diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 9a3d6d8ec..58c02b471 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -1,7 +1,8 @@ -set([ - 'saml2.onelogin.strict' => false, - ]); - - $viewer = $this->getViewer(); - $viewer->email = 'user@example.com'; - $viewer->save(); - - $this->withPost(['SAMLResponse' => $this->acsPostData], function () { - $acsPost = $this->post('/saml2/acs'); - $acsPost->assertRedirect('/'); - $errorMessage = session()->get('error'); - $this->assertEquals('A user with the email user@example.com already exists but with different credentials.', $errorMessage); - }); - } - public function test_saml_routes_are_only_active_if_saml_enabled() { config()->set(['auth.method' => 'standard']); @@ -289,6 +272,62 @@ class Saml2Test extends TestCase }); } + public function test_group_sync_functions_when_email_confirmation_required() + { + setting()->put('registration-confirmation', 'true'); + config()->set([ + 'saml2.onelogin.strict' => false, + 'saml2.user_to_groups' => true, + 'saml2.remove_from_groups' => false, + ]); + + $memberRole = factory(Role::class)->create(['external_auth_id' => 'member']); + $adminRole = Role::getSystemRole('admin'); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () use ($memberRole, $adminRole) { + $acsPost = $this->followingRedirects()->post('/saml2/acs'); + + $this->assertEquals('http://localhost/register/confirm', url()->current()); + $acsPost->assertSee('Please check your email and click the confirmation button to access BookStack.'); + $user = User::query()->where('external_auth_id', '=', 'user')->first(); + + $userRoleIds = $user->roles()->pluck('id'); + $this->assertContains($memberRole->id, $userRoleIds, 'User was assigned to member role'); + $this->assertContains($adminRole->id, $userRoleIds, 'User was assigned to admin role'); + $this->assertTrue($user->email_confirmed == false, 'User email remains unconfirmed'); + }); + + $homeGet = $this->get('/'); + $homeGet->assertRedirect('/register/confirm/awaiting'); + } + + public function test_login_where_existing_non_saml_user_shows_warning() + { + $this->post('/saml2/login'); + config()->set(['saml2.onelogin.strict' => false]); + + // Make the user pre-existing in DB with different auth_id + User::query()->forceCreate([ + 'email' => 'user@example.com', + 'external_auth_id' => 'old_system_user_id', + 'email_confirmed' => false, + 'name' => 'Barry Scott' + ]); + + $this->withPost(['SAMLResponse' => $this->acsPostData], function () { + $acsPost = $this->post('/saml2/acs'); + $acsPost->assertRedirect('/login'); + $this->assertFalse($this->isAuthenticated()); + $this->assertDatabaseHas('users', [ + 'email' => 'user@example.com', + 'external_auth_id' => 'old_system_user_id', + ]); + + $loginGet = $this->get('/login'); + $loginGet->assertSee("A user with the email user@example.com already exists but with different credentials"); + }); + } + protected function withGet(array $options, callable $callback) { return $this->withGlobal($_GET, $options, $callback); diff --git a/tests/Auth/SocialAuthTest.php b/tests/Auth/SocialAuthTest.php index 1a7a3fccc..d448b567e 100644 --- a/tests/Auth/SocialAuthTest.php +++ b/tests/Auth/SocialAuthTest.php @@ -1,10 +1,11 @@ -create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]); - $chapter = factory(\BookStack\Entities\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]); - $page = factory(\BookStack\Entities\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]); + $book = factory(\BookStack\Entities\Models\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]); + $chapter = factory(\BookStack\Entities\Models\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]); + $page = factory(\BookStack\Entities\Models\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]); $restrictionService = $this->app[PermissionService::class]; $restrictionService->buildJointPermissionsForEntity($book); return [ diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index 099af2939..8c6ea84bf 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -1,10 +1,14 @@ asEditor(); $page = Page::first(); - \Activity::add($page, 'page_update', $page->book->id); + \Activity::addForEntity($page, ActivityType::PAGE_UPDATE); $this->assertDatabaseHas('activities', [ - 'key' => 'page_update', + 'type' => 'page_update', 'entity_id' => $page->id, 'user_id' => $this->getEditor()->id ]); @@ -47,7 +51,7 @@ class CommandsTest extends TestCase $this->assertDatabaseMissing('activities', [ - 'key' => 'page_update' + 'type' => 'page_update' ]); } @@ -166,4 +170,53 @@ class CommandsTest extends TestCase $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]); $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]); } + + public function test_update_url_command_updates_page_content() + { + $page = Page::query()->first(); + $page->html = ''; + $page->save(); + + $this->artisan('bookstack:update-url https://example.com https://cats.example.com') + ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y') + ->expectsQuestion("This operation could cause issues if used incorrectly. Have you made a backup of your existing database?", 'y'); + + $this->assertDatabaseHas('pages', [ + 'id' => $page->id, + 'html' => '' + ]); + } + + public function test_update_url_command_requires_valid_url() + { + $badUrlMessage = "The given urls are expected to be full urls starting with http:// or https://"; + $this->artisan('bookstack:update-url //example.com https://cats.example.com')->expectsOutput($badUrlMessage); + $this->artisan('bookstack:update-url https://example.com htts://cats.example.com')->expectsOutput($badUrlMessage); + $this->artisan('bookstack:update-url example.com https://cats.example.com')->expectsOutput($badUrlMessage); + + $this->expectException(RuntimeException::class); + $this->artisan('bookstack:update-url https://cats.example.com'); + } + + public function test_regenerate_comment_content_command() + { + Comment::query()->forceCreate([ + 'html' => 'some_old_content', + 'text' => 'some_fresh_content', + ]); + + $this->assertDatabaseHas('comments', [ + 'html' => 'some_old_content', + ]); + + $exitCode = \Artisan::call('bookstack:regenerate-comment-content'); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + + $this->assertDatabaseMissing('comments', [ + 'html' => 'some_old_content', + ]); + $this->assertDatabaseHas('comments', [ + 'html' => "

some_fresh_content

\n", + ]); + } } diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index a318ebe24..9b3290370 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -1,10 +1,11 @@ -assertElementContains('a', 'New Shelf'); } + public function test_book_not_visible_in_shelf_list_view_if_user_cant_view_shelf() + { + config()->set([ + 'app.views.bookshelves' => 'list', + ]); + $shelf = Bookshelf::query()->first(); + $book = $shelf->books()->first(); + + $resp = $this->asEditor()->get('/shelves'); + $resp->assertSee($book->name); + $resp->assertSee($book->getUrl()); + + $this->setEntityRestrictions($book, []); + + $resp = $this->asEditor()->get('/shelves'); + $resp->assertDontSee($book->name); + $resp->assertDontSee($book->getUrl()); + } + public function test_shelves_create() { $booksToInclude = Book::take(2)->get(); @@ -202,16 +222,25 @@ class BookShelfTest extends TestCase public function test_shelf_delete() { - $shelf = Bookshelf::first(); - $resp = $this->asEditor()->get($shelf->getUrl('/delete')); - $resp->assertSeeText('Delete Bookshelf'); - $resp->assertSee("action=\"{$shelf->getUrl()}\""); + $shelf = Bookshelf::query()->whereHas('books')->first(); + $this->assertNull($shelf->deleted_at); + $bookCount = $shelf->books()->count(); - $resp = $this->delete($shelf->getUrl()); - $resp->assertRedirect('/shelves'); - $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]); - $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]); - $this->assertSessionHas('success'); + $deleteViewReq = $this->asEditor()->get($shelf->getUrl('/delete')); + $deleteViewReq->assertSeeText('Are you sure you want to delete this bookshelf?'); + + $deleteReq = $this->delete($shelf->getUrl()); + $deleteReq->assertRedirect(url('/shelves')); + $this->assertActivityExists('bookshelf_delete', $shelf); + + $shelf->refresh(); + $this->assertNotNull($shelf->deleted_at); + + $this->assertTrue($shelf->books()->count() === $bookCount); + $this->assertTrue($shelf->deletions()->count() === 1); + + $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); + $redirectReq->assertNotificationContains('Bookshelf Successfully Deleted'); } public function test_shelf_copy_permissions() @@ -263,4 +292,32 @@ class BookShelfTest extends TestCase $pageVisit->assertElementNotContains('.breadcrumbs', $shelf->getShortName()); } + public function test_bookshelves_show_on_book() + { + // Create shelf + $shelfInfo = [ + 'name' => 'My test shelf' . Str::random(4), + 'description' => 'Test shelf description ' . Str::random(10) + ]; + + $this->asEditor()->post('/shelves', $shelfInfo); + $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); + + // Create book and add to shelf + $this->asEditor()->post($shelf->getUrl('/create-book'), [ + 'name' => 'Test book name', + 'description' => 'Book in shelf description' + ]); + + $newBook = Book::query()->orderBy('id', 'desc')->first(); + + $resp = $this->asEditor()->get($newBook->getUrl()); + $resp->assertElementContains('.tri-layout-left-contents', $shelfInfo['name']); + + // Remove shelf + $this->delete($shelf->getUrl()); + + $resp = $this->asEditor()->get($newBook->getUrl()); + $resp->assertDontSee($shelfInfo['name']); + } } diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php new file mode 100644 index 000000000..6c2cf30d4 --- /dev/null +++ b/tests/Entity/BookTest.php @@ -0,0 +1,34 @@ +whereHas('pages')->whereHas('chapters')->first(); + $this->assertNull($book->deleted_at); + $pageCount = $book->pages()->count(); + $chapterCount = $book->chapters()->count(); + + $deleteViewReq = $this->asEditor()->get($book->getUrl('/delete')); + $deleteViewReq->assertSeeText('Are you sure you want to delete this book?'); + + $deleteReq = $this->delete($book->getUrl()); + $deleteReq->assertRedirect(url('/books')); + $this->assertActivityExists('book_delete', $book); + + $book->refresh(); + $this->assertNotNull($book->deleted_at); + + $this->assertTrue($book->pages()->count() === 0); + $this->assertTrue($book->chapters()->count() === 0); + $this->assertTrue($book->pages()->withTrashed()->count() === $pageCount); + $this->assertTrue($book->chapters()->withTrashed()->count() === $chapterCount); + $this->assertTrue($book->deletions()->count() === 1); + + $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); + $redirectReq->assertNotificationContains('Book Successfully Deleted'); + } +} \ No newline at end of file diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php new file mode 100644 index 000000000..e9350a32b --- /dev/null +++ b/tests/Entity/ChapterTest.php @@ -0,0 +1,31 @@ +whereHas('pages')->first(); + $this->assertNull($chapter->deleted_at); + $pageCount = $chapter->pages()->count(); + + $deleteViewReq = $this->asEditor()->get($chapter->getUrl('/delete')); + $deleteViewReq->assertSeeText('Are you sure you want to delete this chapter?'); + + $deleteReq = $this->delete($chapter->getUrl()); + $deleteReq->assertRedirect($chapter->getParent()->getUrl()); + $this->assertActivityExists('chapter_delete', $chapter); + + $chapter->refresh(); + $this->assertNotNull($chapter->deleted_at); + + $this->assertTrue($chapter->pages()->count() === 0); + $this->assertTrue($chapter->pages()->withTrashed()->count() === $pageCount); + $this->assertTrue($chapter->deletions()->count() === 1); + + $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); + $redirectReq->assertNotificationContains('Chapter Successfully Deleted'); + } +} \ No newline at end of file diff --git a/tests/Entity/CommentSettingTest.php b/tests/Entity/CommentSettingTest.php index 967e550a7..49ceede9f 100644 --- a/tests/Entity/CommentSettingTest.php +++ b/tests/Entity/CommentSettingTest.php @@ -1,28 +1,35 @@ -page = \BookStack\Entities\Page::first(); - } +class CommentSettingTest extends BrowserKitTest +{ + protected $page; - public function test_comment_disable () { - $this->asAdmin(); + public function setUp(): void + { + parent::setUp(); + $this->page = Page::first(); + } - $this->setSettings(['app-disable-comments' => 'true']); + public function test_comment_disable() + { + $this->asAdmin(); - $this->asAdmin()->visit($this->page->getUrl()) - ->pageNotHasElement('.comments-list'); - } + $this->setSettings(['app-disable-comments' => 'true']); - public function test_comment_enable () { - $this->asAdmin(); + $this->asAdmin()->visit($this->page->getUrl()) + ->pageNotHasElement('.comments-list'); + } - $this->setSettings(['app-disable-comments' => 'false']); + public function test_comment_enable() + { + $this->asAdmin(); - $this->asAdmin()->visit($this->page->getUrl()) - ->pageHasElement('.comments-list'); - } + $this->setSettings(['app-disable-comments' => 'false']); + + $this->asAdmin()->visit($this->page->getUrl()) + ->pageHasElement('.comments-list'); + } } \ No newline at end of file diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php index 2b943f96f..63d1a29a2 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -1,7 +1,8 @@ -make(['parent_id' => 2]); - $resp = $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes()); + $resp = $this->postJson("/comment/$page->id", $comment->getAttributes()); $resp->assertStatus(200); $resp->assertSee($comment->text); @@ -35,13 +36,12 @@ class CommentTest extends TestCase $page = Page::first(); $comment = factory(Comment::class)->make(); - $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes()); + $this->postJson("/comment/$page->id", $comment->getAttributes()); $comment = $page->comments()->first(); $newText = 'updated text content'; - $resp = $this->putJson("/ajax/comment/$comment->id", [ + $resp = $this->putJson("/comment/$comment->id", [ 'text' => $newText, - 'html' => '

'.$newText.'

', ]); $resp->assertStatus(200); @@ -60,15 +60,57 @@ class CommentTest extends TestCase $page = Page::first(); $comment = factory(Comment::class)->make(); - $this->postJson("/ajax/page/$page->id/comment", $comment->getAttributes()); + $this->postJson("/comment/$page->id", $comment->getAttributes()); $comment = $page->comments()->first(); - $resp = $this->delete("/ajax/comment/$comment->id"); + $resp = $this->delete("/comment/$comment->id"); $resp->assertStatus(200); $this->assertDatabaseMissing('comments', [ 'id' => $comment->id ]); } + + public function test_comments_converts_markdown_input_to_html() + { + $page = Page::first(); + $this->asAdmin()->postJson("/comment/$page->id", [ + 'text' => '# My Title', + ]); + + $this->assertDatabaseHas('comments', [ + 'entity_id' => $page->id, + 'entity_type' => $page->getMorphClass(), + 'text' => '# My Title', + 'html' => "

My Title

\n", + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee('

My Title

'); + } + + public function test_html_cannot_be_injected_via_comment_content() + { + $this->asAdmin(); + $page = Page::first(); + + $script = '\n\n# sometextinthecomment'; + $this->postJson("/comment/$page->id", [ + 'text' => $script, + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($script); + $pageView->assertSee('sometextinthecomment'); + + $comment = $page->comments()->first(); + $this->putJson("/comment/$comment->id", [ + 'text' => $script . 'updated', + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($script); + $pageView->assertSee('sometextinthecommentupdated'); + } } diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 34c3cd4a8..2b5dc6d74 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -1,10 +1,11 @@ -assertSee($expectedShelf->name); } } + + public function test_search_works_on_updated_page_content() + { + $page = Page::query()->first(); + $this->asEditor(); + + $update = $this->put($page->getUrl(), [ + 'name' => $page->name, + 'html' => '

dog pandabearmonster spaghetti

', + ]); + + $search = $this->asEditor()->get('/search?term=pandabearmonster'); + $search->assertStatus(200); + $search->assertSeeText($page->name); + $search->assertSee($page->getUrl()); + } } diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php index 97684ea4d..3a363e2b8 100644 --- a/tests/Entity/EntityTest.php +++ b/tests/Entity/EntityTest.php @@ -1,12 +1,13 @@ -bookCreation(); $chapter = $this->chapterCreation($book); - $page = $this->pageCreation($chapter); + $this->pageCreation($chapter); // Test Updating - $book = $this->bookUpdate($book); - - // Test Deletion - $this->bookDelete($book); - } - - public function bookDelete(Book $book) - { - $this->asAdmin() - ->visit($book->getUrl()) - // Check link works correctly - ->click('Delete') - ->seePageIs($book->getUrl() . '/delete') - // Ensure the book name is show to user - ->see($book->name) - ->press('Confirm') - ->seePageIs('/books') - ->notSeeInDatabase('books', ['id' => $book->id]); + $this->bookUpdate($book); } public function bookUpdate(Book $book) @@ -271,15 +255,20 @@ class EntityTest extends BrowserKitTest ->seeInElement('#recently-updated-pages', $page->name); } - public function test_slug_multi_byte_lower_casing() + public function test_slug_multi_byte_url_safe() { $book = $this->newBook([ - 'name' => 'КНИГА' + 'name' => 'информация' ]); - $this->assertEquals('книга', $book->slug); - } + $this->assertEquals('informatsiya', $book->slug); + $book = $this->newBook([ + 'name' => '¿Qué?' + ]); + + $this->assertEquals('que', $book->slug); + } public function test_slug_format() { diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 9a2d32028..e022f92f5 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -1,10 +1,11 @@ -page = \BookStack\Entities\Page::first(); + $this->page = \BookStack\Entities\Models\Page::first(); } protected function setMarkdownEditor() diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 8a78c8ac0..51a8568bf 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -1,7 +1,8 @@ -assertSee($content); } + public function test_page_includes_rendered_on_book_export() + { + $page = Page::query()->first(); + $secondPage = Page::query() + ->where('book_id', '!=', $page->book_id) + ->first(); + + $content = '

my cat is awesome and scratchy

'; + $secondPage->html = $content; + $secondPage->save(); + + $page->html = "{{@{$secondPage->id}#bkmrk-meow}}"; + $page->save(); + + $this->asEditor(); + $htmlContent = $this->get($page->book->getUrl('/export/html')); + $htmlContent->assertSee('my cat is awesome and scratchy'); + } + public function test_page_content_scripts_removed_by_default() { $this->asEditor(); @@ -139,6 +159,72 @@ class PageContentTest extends TestCase } + public function test_javascript_uri_links_are_removed() + { + $checks = [ + ''); + $pageView->assertElementNotContains('.page-content', 'href=javascript:'); + } + } + public function test_form_actions_with_javascript_are_removed() + { + $checks = [ + '
', + '
', + '
' + ]; + + $this->asEditor(); + $page = Page::first(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertElementNotContains('.page-content', '