From: Dan Brown Date: Mon, 24 May 2021 16:06:50 +0000 (+0100) Subject: Merge branch 'master' of https://github.com/jasonhoule/BookStack into jasonhoule... X-Git-Tag: v21.05~1^2~20 X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/85db812feaae5f36ea6214931cec4adb67a9cb39?hp=a192b600fc818313ef26b139a9a2d11d4d17f0a4 Merge branch 'master' of https://github.com/jasonhoule/BookStack into jasonhoule-master --- diff --git a/.env.example b/.env.example index 47f2367b0..05383f04a 100644 --- a/.env.example +++ b/.env.example @@ -12,11 +12,13 @@ APP_KEY=SomeRandomString # Application URL -# Remove the hash below and set a URL if using BookStack behind -# 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 +# All URLs in BookStack will be generated using this value +# to ensure URLs generated are consistent and secure. +# If you change this in the future you may need to run a command +# to update stored URLs in the database. Command example: +# php artisan bookstack:update-url https://old.example.com https://new.example.com +APP_URL=https://example.com # Database details DB_HOST=localhost @@ -28,8 +30,8 @@ DB_PASSWORD=database_user_password # Can be 'smtp' or 'sendmail' MAIL_DRIVER=smtp -# Mail sender options -MAIL_FROM_NAME=BookStack +# Mail sender details +MAIL_FROM_NAME="BookStack" MAIL_FROM=bookstack@example.com # SMTP mail options diff --git a/.env.example.complete b/.env.example.complete index 45b1e1321..71fe66bca 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -51,7 +51,7 @@ DB_USERNAME=database_username DB_PASSWORD=database_user_password # Mail system to use -# Can be 'smtp', 'mail' or 'sendmail' +# Can be 'smtp' or 'sendmail' MAIL_DRIVER=smtp # Mail sending options @@ -195,6 +195,7 @@ LDAP_DN=false LDAP_PASS=false LDAP_USER_FILTER=false LDAP_VERSION=false +LDAP_START_TLS=false LDAP_TLS_INSECURE=false LDAP_ID_ATTRIBUTE=uid LDAP_EMAIL_ATTRIBUTE=mail @@ -221,6 +222,7 @@ SAML2_IDP_x509=null SAML2_ONELOGIN_OVERRIDES=null SAML2_DUMP_USER_DETAILS=false SAML2_AUTOLOAD_METADATA=false +SAML2_IDP_AUTHNCONTEXT=true # SAML group sync configuration # Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/ @@ -245,16 +247,29 @@ AVATAR_URL= DRAWIO=true # Default item listing view -# Used for public visitors and user's without a preference -# Can be 'list' or 'grid' +# Used for public visitors and user's without a preference. +# Can be 'list' or 'grid'. APP_VIEWS_BOOKS=list APP_VIEWS_BOOKSHELVES=grid +APP_VIEWS_BOOKSHELF=grid + +# Use dark mode by default +# Will be overriden by any user/session preference. +APP_DEFAULT_DARK_MODE=false # Page revision limit # Number of page revisions to keep in the system before deleting old revisions. # 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 index 13e44d97d..32715dd0a 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -1,7 +1,7 @@ [ 'name' => $shelf->createdBy->name, - ] + ], + 'owned_by' => [ + 'name' => $shelf->ownedBy->name + ], ]); } diff --git a/tests/AuditLogTest.php b/tests/AuditLogTest.php index a2cdc33ff..55a458786 100644 --- a/tests/AuditLogTest.php +++ b/tests/AuditLogTest.php @@ -2,13 +2,24 @@ use BookStack\Actions\Activity; use BookStack\Actions\ActivityService; +use BookStack\Actions\ActivityType; use BookStack\Auth\UserRepo; -use BookStack\Entities\Page; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Tools\TrashCan; +use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use Carbon\Carbon; class AuditLogTest extends TestCase { + /** @var ActivityService */ + protected $activityService; + + public function setUp(): void + { + parent::setUp(); + $this->activityService = app(ActivityService::class); + } public function test_only_accessible_with_right_permissions() { @@ -33,14 +44,14 @@ class AuditLogTest extends TestCase $admin = $this->getAdmin(); $this->actingAs($admin); $page = Page::query()->first(); - app(ActivityService::class)->add($page, 'page_create', $page->book->id); + $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('.audit-log-user', $admin->name); + $resp->assertElementContains('.table-user-item', $admin->name); } public function test_shows_name_for_deleted_items() @@ -48,9 +59,10 @@ class AuditLogTest extends TestCase $this->actingAs( $this->getAdmin()); $page = Page::query()->first(); $pageName = $page->name; - app(ActivityService::class)->add($page, 'page_create', $page->book->id); + $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'); @@ -62,7 +74,7 @@ class AuditLogTest extends TestCase $viewer = $this->getViewer(); $this->actingAs($viewer); $page = Page::query()->first(); - app(ActivityService::class)->add($page, 'page_create', $page->book->id); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); $this->actingAs($this->getAdmin()); app(UserRepo::class)->destroy($viewer); @@ -75,7 +87,7 @@ class AuditLogTest extends TestCase { $this->actingAs($this->getAdmin()); $page = Page::query()->first(); - app(ActivityService::class)->add($page, 'page_create', $page->book->id); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); $resp = $this->get('settings/audit'); $resp->assertSeeText($page->name); @@ -88,7 +100,7 @@ class AuditLogTest extends TestCase { $this->actingAs($this->getAdmin()); $page = Page::query()->first(); - app(ActivityService::class)->add($page, 'page_create', $page->book->id); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); $yesterday = (Carbon::now()->subDay()->format('Y-m-d')); $tomorrow = (Carbon::now()->addDay()->format('Y-m-d')); @@ -106,4 +118,26 @@ class AuditLogTest extends TestCase $resp->assertDontSeeText($page->name); } + public function test_user_filter() + { + $admin = $this->getAdmin(); + $editor = $this->getEditor(); + $this->actingAs($admin); + $page = Page::query()->first(); + $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + + $this->actingAs($editor); + $chapter = Chapter::query()->first(); + $this->activityService->addForEntity($chapter, ActivityType::CHAPTER_UPDATE); + + $resp = $this->actingAs($admin)->get('settings/audit?user=' . $admin->id); + $resp->assertSeeText($page->name); + $resp->assertDontSeeText($chapter->name); + + $resp = $this->actingAs($admin)->get('settings/audit?user=' . $editor->id); + $resp->assertSeeText($chapter->name); + $resp->assertDontSeeText($page->name); + + } + } \ No newline at end of file diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index e2b1e0cd6..f88fc1904 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -2,13 +2,14 @@ use BookStack\Auth\Role; use BookStack\Auth\User; -use BookStack\Entities\Page; +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; class AuthTest extends BrowserKitTest @@ -221,6 +222,7 @@ class AuthTest extends BrowserKitTest public function test_user_creation() { + /** @var User $user */ $user = factory(User::class)->make(); $adminRole = Role::getRole('admin'); @@ -234,8 +236,11 @@ class AuthTest extends BrowserKitTest ->type($user->password, '#password-confirm') ->press('Save') ->seePageIs('/settings/users') - ->seeInDatabase('users', $user->toArray()) + ->seeInDatabase('users', $user->only(['name', 'email'])) ->see($user->name); + + $user->refresh(); + $this->assertStringStartsWith(Str::slug($user->name), $user->slug); } public function test_user_updating() @@ -252,6 +257,9 @@ class AuthTest extends BrowserKitTest ->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() diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 3cb39ca2c..840dfd630 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -4,6 +4,7 @@ use BookStack\Auth\Access\LdapService; use BookStack\Auth\Role; use BookStack\Auth\Access\Ldap; use BookStack\Auth\User; +use BookStack\Exceptions\LdapException; use Mockery\MockInterface; use Tests\BrowserKitTest; @@ -40,6 +41,14 @@ class LdapTest extends BrowserKitTest $this->mockUser = factory(User::class)->make(); } + protected function runFailedAuthLogin() + { + $this->commonLdapMocks(1, 1, 1, 1, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->andReturn(['count' => 0]); + $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']); + } + protected function mockEscapes($times = 1) { $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function($val) { @@ -550,6 +559,22 @@ class LdapTest extends BrowserKitTest ]); } + public function test_start_tls_called_if_option_set() + { + config()->set(['services.ldap.start_tls' => true]); + $this->mockLdap->shouldReceive('startTls')->once()->andReturn(true); + $this->runFailedAuthLogin(); + } + + public function test_connection_fails_if_tls_fails() + { + config()->set(['services.ldap.start_tls' => true]); + $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false); + $this->commonLdapMocks(1, 1, 0, 0, 0); + $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']); + $this->assertResponseStatus(500); + } + public function test_ldap_attributes_can_be_binary_decoded_if_marked() { config()->set(['services.ldap.id_attribute' => 'BIN;uid']); @@ -640,12 +665,7 @@ class LdapTest extends BrowserKitTest { $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->runFailedAuthLogin(); $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins')); } } diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 58c02b471..b6b02e2f7 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -28,6 +28,7 @@ class Saml2Test extends TestCase 'saml2.autoload_from_metadata' => false, 'saml2.onelogin.idp.x509cert' => $this->testCert, 'saml2.onelogin.debug' => false, + 'saml2.onelogin.security.requestedAuthnContext' => true, ]); } @@ -328,6 +329,40 @@ class Saml2Test extends TestCase }); } + public function test_login_request_contains_expected_default_authncontext() + { + $authReq = $this->getAuthnRequest(); + $this->assertStringContainsString('samlp:RequestedAuthnContext Comparison="exact"', $authReq); + $this->assertStringContainsString('urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', $authReq); + } + + public function test_false_idp_authncontext_option_does_not_pass_authncontext_in_saml_request() + { + config()->set(['saml2.onelogin.security.requestedAuthnContext' => false]); + $authReq = $this->getAuthnRequest(); + $this->assertStringNotContainsString('samlp:RequestedAuthnContext', $authReq); + $this->assertStringNotContainsString('', $authReq); + } + + public function test_array_idp_authncontext_option_passes_value_as_authncontextclassref_in_request() + { + config()->set(['saml2.onelogin.security.requestedAuthnContext' => ['urn:federation:authentication:windows', 'urn:federation:authentication:linux']]); + $authReq = $this->getAuthnRequest(); + $this->assertStringContainsString('samlp:RequestedAuthnContext', $authReq); + $this->assertStringContainsString('urn:federation:authentication:windows', $authReq); + $this->assertStringContainsString('urn:federation:authentication:linux', $authReq); + } + + protected function getAuthnRequest(): string + { + $req = $this->post('/saml2/login'); + $location = $req->headers->get('Location'); + $query = explode('?', $location)[1]; + $params = []; + parse_str($query, $params); + return gzinflate(base64_decode($params['SAMLRequest'])); + } + 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 d448b567e..4369d8b7a 100644 --- a/tests/Auth/SocialAuthTest.php +++ b/tests/Auth/SocialAuthTest.php @@ -17,8 +17,7 @@ class SocialAuthTest extends TestCase $this->setSettings(['registration-enabled' => 'true']); config(['GOOGLE_APP_ID' => 'abc123', 'GOOGLE_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']); - $mockSocialite = Mockery::mock(Factory::class); - $this->app[Factory::class] = $mockSocialite; + $mockSocialite = $this->mock(Factory::class); $mockSocialDriver = Mockery::mock(Provider::class); $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); @@ -46,8 +45,7 @@ class SocialAuthTest extends TestCase 'APP_URL' => 'http://localhost' ]); - $mockSocialite = Mockery::mock(Factory::class); - $this->app[Factory::class] = $mockSocialite; + $mockSocialite = $this->mock(Factory::class); $mockSocialDriver = Mockery::mock(Provider::class); $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); @@ -93,8 +91,7 @@ class SocialAuthTest extends TestCase ]); $user = factory(User::class)->make(); - $mockSocialite = Mockery::mock(Factory::class); - $this->app[Factory::class] = $mockSocialite; + $mockSocialite = $this->mock(Factory::class); $mockSocialDriver = Mockery::mock(Provider::class); $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); @@ -132,8 +129,7 @@ class SocialAuthTest extends TestCase ]); $user = factory(User::class)->make(); - $mockSocialite = Mockery::mock(Factory::class); - $this->app[Factory::class] = $mockSocialite; + $mockSocialite = $this->mock(Factory::class); $mockSocialDriver = Mockery::mock(Provider::class); $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); @@ -169,8 +165,7 @@ class SocialAuthTest extends TestCase $this->setSettings(['registration-enabled' => 'true']); config(['GITHUB_APP_ID' => 'abc123', 'GITHUB_APP_SECRET' => '123abc', 'APP_URL' => 'http://localhost']); - $mockSocialite = Mockery::mock(Factory::class); - $this->app[Factory::class] = $mockSocialite; + $mockSocialite = $this->mock(Factory::class); $mockSocialDriver = Mockery::mock(Provider::class); $mockSocialUser = Mockery::mock(\Laravel\Socialite\Contracts\User::class); diff --git a/tests/Auth/UserInviteTest.php b/tests/Auth/UserInviteTest.php index f2a1d0e78..b6f521eaa 100644 --- a/tests/Auth/UserInviteTest.php +++ b/tests/Auth/UserInviteTest.php @@ -18,13 +18,15 @@ class UserInviteTest extends TestCase Notification::fake(); $admin = $this->getAdmin(); - $this->actingAs($admin)->post('/settings/users/create', [ + $email = Str::random(16) . '@example.com'; + $resp = $this->actingAs($admin)->post('/settings/users/create', [ 'name' => 'Barry', - 'email' => 'tester@example.com', + 'email' => $email, 'send_invite' => 'true', ]); + $resp->assertRedirect('/settings/users'); - $newUser = User::query()->where('email', '=', 'tester@example.com')->orderBy('id', 'desc')->first(); + $newUser = User::query()->where('email', '=', $email)->orderBy('id', 'desc')->first(); Notification::assertSentTo($newUser, UserInvite::class); $this->assertDatabaseHas('user_invites', [ diff --git a/tests/BrowserKitTest.php b/tests/BrowserKitTest.php index b81afe311..45e20b5e2 100644 --- a/tests/BrowserKitTest.php +++ b/tests/BrowserKitTest.php @@ -1,10 +1,15 @@ get()->last(); + return User::where('system_name', '=', null)->get()->last(); } /** @@ -64,23 +69,21 @@ abstract class BrowserKitTest extends TestCase /** * Create a group of entities that belong to a specific user. - * @param $creatorUser - * @param $updaterUser - * @return array */ - protected function createEntityChainBelongingToUser($creatorUser, $updaterUser = false) + protected function createEntityChainBelongingToUser(User $creatorUser, ?User $updaterUser = null): array { - if ($updaterUser === false) $updaterUser = $creatorUser; - $book = factory(\BookStack\Entities\Book::class)->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]); + if (empty($updaterUser)) { + $updaterUser = $creatorUser; + } + + $userAttrs = ['created_by' => $creatorUser->id, 'owned_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]; + $book = factory(Book::class)->create($userAttrs); + $chapter = factory(Chapter::class)->create(array_merge(['book_id' => $book->id], $userAttrs)); + $page = factory(Page::class)->create(array_merge(['book_id' => $book->id, 'chapter_id' => $chapter->id], $userAttrs)); $restrictionService = $this->app[PermissionService::class]; $restrictionService->buildJointPermissionsForEntity($book); - return [ - 'book' => $book, - 'chapter' => $chapter, - 'page' => $page - ]; + + return compact('book', 'chapter', 'page'); } /** @@ -101,7 +104,7 @@ abstract class BrowserKitTest extends TestCase */ protected function getNewBlankUser($attributes = []) { - $user = factory(\BookStack\Auth\User::class)->create($attributes); + $user = factory(User::class)->create($attributes); return $user; } @@ -116,7 +119,7 @@ abstract class BrowserKitTest extends TestCase */ protected function seeInNthElement($element, $position, $text, $negate = false) { - $method = $negate ? 'assertNotRegExp' : 'assertRegExp'; + $method = $negate ? 'assertDoesNotMatchRegularExpression' : 'assertMatchesRegularExpression'; $rawPattern = preg_quote($text, '/'); diff --git a/tests/Commands/AddAdminCommandTest.php b/tests/Commands/AddAdminCommandTest.php new file mode 100644 index 000000000..6b03c86f9 --- /dev/null +++ b/tests/Commands/AddAdminCommandTest.php @@ -0,0 +1,25 @@ + 'admintest@example.com', + '--name' => 'Admin Test', + '--password' => 'testing-4', + ]); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + + $this->assertDatabaseHas('users', [ + 'email' => 'admintest@example.com', + 'name' => 'Admin Test' + ]); + + $this->assertTrue(User::query()->where('email', '=', 'admintest@example.com')->first()->hasSystemRole('admin'), 'User has admin role as expected'); + $this->assertTrue(\Auth::attempt(['email' => 'admintest@example.com', 'password' => 'testing-4']), 'Password stored as expected'); + } +} \ No newline at end of file diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php new file mode 100644 index 000000000..751a165c6 --- /dev/null +++ b/tests/Commands/ClearActivityCommandTest.php @@ -0,0 +1,33 @@ +asEditor(); + $page = Page::first(); + \Activity::addForEntity($page, ActivityType::PAGE_UPDATE); + + $this->assertDatabaseHas('activities', [ + 'type' => 'page_update', + 'entity_id' => $page->id, + 'user_id' => $this->getEditor()->id + ]); + + + DB::rollBack(); + $exitCode = \Artisan::call('bookstack:clear-activity'); + DB::beginTransaction(); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + + + $this->assertDatabaseMissing('activities', [ + 'type' => 'page_update' + ]); + } +} \ No newline at end of file diff --git a/tests/Commands/ClearRevisionsCommandTest.php b/tests/Commands/ClearRevisionsCommandTest.php new file mode 100644 index 000000000..e0293faf1 --- /dev/null +++ b/tests/Commands/ClearRevisionsCommandTest.php @@ -0,0 +1,47 @@ +asEditor(); + $pageRepo = app(PageRepo::class); + $page = Page::first(); + $pageRepo->update($page, ['name' => 'updated page', 'html' => '

new content

', 'summary' => 'page revision testing']); + $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '

new content in draft

', 'summary' => 'page revision testing']); + + $this->assertDatabaseHas('page_revisions', [ + 'page_id' => $page->id, + 'type' => 'version' + ]); + $this->assertDatabaseHas('page_revisions', [ + 'page_id' => $page->id, + 'type' => 'update_draft' + ]); + + $exitCode = Artisan::call('bookstack:clear-revisions'); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + + $this->assertDatabaseMissing('page_revisions', [ + 'page_id' => $page->id, + 'type' => 'version' + ]); + $this->assertDatabaseHas('page_revisions', [ + 'page_id' => $page->id, + 'type' => 'update_draft' + ]); + + $exitCode = Artisan::call('bookstack:clear-revisions', ['--all' => true]); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + + $this->assertDatabaseMissing('page_revisions', [ + 'page_id' => $page->id, + 'type' => 'update_draft' + ]); + } +} \ No newline at end of file diff --git a/tests/Commands/ClearViewsCommandTest.php b/tests/Commands/ClearViewsCommandTest.php new file mode 100644 index 000000000..04665adcf --- /dev/null +++ b/tests/Commands/ClearViewsCommandTest.php @@ -0,0 +1,32 @@ +asEditor(); + $page = Page::first(); + + $this->get($page->getUrl()); + + $this->assertDatabaseHas('views', [ + 'user_id' => $this->getEditor()->id, + 'viewable_id' => $page->id, + 'views' => 1 + ]); + + DB::rollBack(); + $exitCode = \Artisan::call('bookstack:clear-views'); + DB::beginTransaction(); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + + $this->assertDatabaseMissing('views', [ + 'user_id' => $this->getEditor()->id + ]); + } +} \ No newline at end of file diff --git a/tests/Commands/CopyShelfPermissionsCommandTest.php b/tests/Commands/CopyShelfPermissionsCommandTest.php new file mode 100644 index 000000000..87199bdc3 --- /dev/null +++ b/tests/Commands/CopyShelfPermissionsCommandTest.php @@ -0,0 +1,54 @@ +artisan('bookstack:copy-shelf-permissions') + ->expectsOutput('Either a --slug or --all option must be provided.') + ->assertExitCode(0); + } + + public function test_copy_shelf_permissions_command_using_slug() + { + $shelf = Bookshelf::first(); + $child = $shelf->books()->first(); + $editorRole = $this->getEditor()->roles()->first(); + $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default"); + $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default"); + + $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]); + $this->artisan('bookstack:copy-shelf-permissions', [ + '--slug' => $shelf->slug, + ]); + $child = $shelf->books()->first(); + + $this->assertTrue(boolval($child->restricted), "Child book should now be restricted"); + $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions"); + $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_copy_shelf_permissions_command_using_all() + { + $shelf = Bookshelf::query()->first(); + Bookshelf::query()->where('id', '!=', $shelf->id)->delete(); + $child = $shelf->books()->first(); + $editorRole = $this->getEditor()->roles()->first(); + $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default"); + $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default"); + + $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]); + $this->artisan('bookstack:copy-shelf-permissions --all') + ->expectsQuestion('Permission settings for all shelves will be cascaded. Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. Are you sure you want to proceed?', 'y'); + $child = $shelf->books()->first(); + + $this->assertTrue(boolval($child->restricted), "Child book should now be restricted"); + $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions"); + $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]); + } +} \ No newline at end of file diff --git a/tests/Commands/RegenerateCommentContentCommandTest.php b/tests/Commands/RegenerateCommentContentCommandTest.php new file mode 100644 index 000000000..1deeaa703 --- /dev/null +++ b/tests/Commands/RegenerateCommentContentCommandTest.php @@ -0,0 +1,29 @@ +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", + ]); + } +} \ No newline at end of file diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php new file mode 100644 index 000000000..d5b34ba17 --- /dev/null +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -0,0 +1,24 @@ +truncate(); + $page = Page::first(); + + $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]); + + $exitCode = \Artisan::call('bookstack:regenerate-permissions'); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + DB::beginTransaction(); + + $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]); + } +} \ No newline at end of file diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php new file mode 100644 index 000000000..7043ce047 --- /dev/null +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -0,0 +1,59 @@ +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_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_command_updates_settings() + { + setting()->put('my-custom-item', 'https://example.com/donkey/cat'); + $this->runUpdate('https://example.com', 'https://cats.example.com'); + + $settingVal = setting('my-custom-item'); + $this->assertEquals('https://cats.example.com/donkey/cat', $settingVal); + } + + public function test_command_updates_array_settings() + { + setting()->put('my-custom-array-item', [['name' => 'a https://example.com/donkey/cat url']]); + $this->runUpdate('https://example.com', 'https://cats.example.com'); + $settingVal = setting('my-custom-array-item'); + $this->assertEquals('a https://cats.example.com/donkey/cat url', $settingVal[0]['name']); + } + + protected function runUpdate(string $oldUrl, string $newUrl) + { + $this->artisan("bookstack:update-url {$oldUrl} {$newUrl}") + ->expectsQuestion("This will search for \"{$oldUrl}\" in your database and replace it with \"{$newUrl}\".\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'); + } +} \ No newline at end of file diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php deleted file mode 100644 index bfc0ac0eb..000000000 --- a/tests/CommandsTest.php +++ /dev/null @@ -1,221 +0,0 @@ -asEditor(); - $page = Page::first(); - - $this->get($page->getUrl()); - - $this->assertDatabaseHas('views', [ - 'user_id' => $this->getEditor()->id, - 'viewable_id' => $page->id, - 'views' => 1 - ]); - - $exitCode = \Artisan::call('bookstack:clear-views'); - $this->assertTrue($exitCode === 0, 'Command executed successfully'); - - $this->assertDatabaseMissing('views', [ - 'user_id' => $this->getEditor()->id - ]); - } - - public function test_clear_activity_command() - { - $this->asEditor(); - $page = Page::first(); - \Activity::add($page, 'page_update', $page->book->id); - - $this->assertDatabaseHas('activities', [ - 'key' => 'page_update', - 'entity_id' => $page->id, - 'user_id' => $this->getEditor()->id - ]); - - $exitCode = \Artisan::call('bookstack:clear-activity'); - $this->assertTrue($exitCode === 0, 'Command executed successfully'); - - - $this->assertDatabaseMissing('activities', [ - 'key' => 'page_update' - ]); - } - - public function test_clear_revisions_command() - { - $this->asEditor(); - $pageRepo = app(PageRepo::class); - $page = Page::first(); - $pageRepo->update($page, ['name' => 'updated page', 'html' => '

new content

', 'summary' => 'page revision testing']); - $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '

new content in draft

', 'summary' => 'page revision testing']); - - $this->assertDatabaseHas('page_revisions', [ - 'page_id' => $page->id, - 'type' => 'version' - ]); - $this->assertDatabaseHas('page_revisions', [ - 'page_id' => $page->id, - 'type' => 'update_draft' - ]); - - $exitCode = \Artisan::call('bookstack:clear-revisions'); - $this->assertTrue($exitCode === 0, 'Command executed successfully'); - - $this->assertDatabaseMissing('page_revisions', [ - 'page_id' => $page->id, - 'type' => 'version' - ]); - $this->assertDatabaseHas('page_revisions', [ - 'page_id' => $page->id, - 'type' => 'update_draft' - ]); - - $exitCode = \Artisan::call('bookstack:clear-revisions', ['--all' => true]); - $this->assertTrue($exitCode === 0, 'Command executed successfully'); - - $this->assertDatabaseMissing('page_revisions', [ - 'page_id' => $page->id, - 'type' => 'update_draft' - ]); - } - - public function test_regen_permissions_command() - { - JointPermission::query()->truncate(); - $page = Page::first(); - - $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]); - - $exitCode = \Artisan::call('bookstack:regenerate-permissions'); - $this->assertTrue($exitCode === 0, 'Command executed successfully'); - - $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]); - } - - public function test_add_admin_command() - { - $exitCode = \Artisan::call('bookstack:create-admin', [ - '--email' => 'admintest@example.com', - '--name' => 'Admin Test', - '--password' => 'testing-4', - ]); - $this->assertTrue($exitCode === 0, 'Command executed successfully'); - - $this->assertDatabaseHas('users', [ - 'email' => 'admintest@example.com', - 'name' => 'Admin Test' - ]); - - $this->assertTrue(User::where('email', '=', 'admintest@example.com')->first()->hasSystemRole('admin'), 'User has admin role as expected'); - $this->assertTrue(\Auth::attempt(['email' => 'admintest@example.com', 'password' => 'testing-4']), 'Password stored as expected'); - } - - public function test_copy_shelf_permissions_command_shows_error_when_no_required_option_given() - { - $this->artisan('bookstack:copy-shelf-permissions') - ->expectsOutput('Either a --slug or --all option must be provided.') - ->assertExitCode(0); - } - - public function test_copy_shelf_permissions_command_using_slug() - { - $shelf = Bookshelf::first(); - $child = $shelf->books()->first(); - $editorRole = $this->getEditor()->roles()->first(); - $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default"); - $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default"); - - $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]); - $this->artisan('bookstack:copy-shelf-permissions', [ - '--slug' => $shelf->slug, - ]); - $child = $shelf->books()->first(); - - $this->assertTrue(boolval($child->restricted), "Child book should now be restricted"); - $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions"); - $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_copy_shelf_permissions_command_using_all() - { - $shelf = Bookshelf::query()->first(); - Bookshelf::query()->where('id', '!=', $shelf->id)->delete(); - $child = $shelf->books()->first(); - $editorRole = $this->getEditor()->roles()->first(); - $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default"); - $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default"); - - $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]); - $this->artisan('bookstack:copy-shelf-permissions --all') - ->expectsQuestion('Permission settings for all shelves will be cascaded. Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. Are you sure you want to proceed?', 'y'); - $child = $shelf->books()->first(); - - $this->assertTrue(boolval($child->restricted), "Child book should now be restricted"); - $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions"); - $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 cb3acfb1e..60658f6b2 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -1,8 +1,8 @@ set([ - 'app.views.bookshelves' => 'list', + 'setting-defaults.user.bookshelves_view_type' => 'list', ]); $shelf = Bookshelf::query()->first(); $book = $shelf->books()->first(); @@ -156,6 +156,47 @@ class BookShelfTest extends TestCase $resp->assertDontSee($shelf->getUrl('/permissions')); } + public function test_shelf_view_has_sort_control_that_defaults_to_default() + { + $shelf = Bookshelf::query()->first(); + $resp = $this->asAdmin()->get($shelf->getUrl()); + $resp->assertElementExists('form[action$="change-sort/shelf_books"]'); + $resp->assertElementContains('form[action$="change-sort/shelf_books"] [aria-haspopup="true"]', 'Default'); + } + + public function test_shelf_view_sort_takes_action() + { + $shelf = Bookshelf::query()->whereHas('books')->with('books')->first(); + $books = Book::query()->take(3)->get(['id', 'name']); + $books[0]->fill(['name' => 'bsfsdfsdfsd'])->save(); + $books[1]->fill(['name' => 'adsfsdfsdfsd'])->save(); + $books[2]->fill(['name' => 'hdgfgdfg'])->save(); + + // Set book ordering + $this->asAdmin()->put($shelf->getUrl(), [ + 'books' => $books->implode('id', ','), + 'tags' => [], 'description' => 'abc', 'name' => 'abc' + ]); + $this->assertEquals(3, $shelf->books()->count()); + $shelf->refresh(); + + $resp = $this->asEditor()->get($shelf->getUrl()); + $resp->assertElementContains('.book-content a.grid-card', $books[0]->name, 1); + $resp->assertElementNotContains('.book-content a.grid-card', $books[0]->name, 3); + + setting()->putUser($this->getEditor(), 'shelf_books_sort_order', 'desc'); + $resp = $this->asEditor()->get($shelf->getUrl()); + $resp->assertElementNotContains('.book-content a.grid-card', $books[0]->name, 1); + $resp->assertElementContains('.book-content a.grid-card', $books[0]->name, 3); + + setting()->putUser($this->getEditor(), 'shelf_books_sort_order', 'desc'); + setting()->putUser($this->getEditor(), 'shelf_books_sort', 'name'); + $resp = $this->asEditor()->get($shelf->getUrl()); + $resp->assertElementContains('.book-content a.grid-card', 'hdgfgdfg', 1); + $resp->assertElementContains('.book-content a.grid-card', 'bsfsdfsdfsd', 2); + $resp->assertElementContains('.book-content a.grid-card', 'adsfsdfsdfsd', 3); + } + public function test_shelf_edit() { $shelf = Bookshelf::first(); @@ -222,16 +263,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()}\""); - - $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'); + $shelf = Bookshelf::query()->whereHas('books')->first(); + $this->assertNull($shelf->deleted_at); + $bookCount = $shelf->books()->count(); + + $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() 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 3c8cae68c..49ceede9f 100644 --- a/tests/Entity/CommentSettingTest.php +++ b/tests/Entity/CommentSettingTest.php @@ -1,6 +1,6 @@ newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']); $this->asEditor(); $editorId = $this->getEditor()->id; + $editorSlug = $this->getEditor()->slug; // Viewed filter searches $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertSee($page->name); @@ -133,16 +134,23 @@ class EntitySearchTest extends TestCase // User filters $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertDontSee($page->name); $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name); - $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorSlug.'}'))->assertDontSee($page->name); $page->created_by = $editorId; $page->save(); $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertSee($page->name); - $this->get('/search?term=' . urlencode('danzorbhsing {created_by:'.$editorId.'}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {created_by: '.$editorSlug.'}'))->assertSee($page->name); $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name); $page->updated_by = $editorId; $page->save(); $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertSee($page->name); - $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorSlug.'}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertDontSee($page->name); + $page->owned_by = $editorId; + $page->save(); + $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:me}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {owned_by:'.$editorSlug.'}'))->assertSee($page->name); // Content filters $this->get('/search?term=' . urlencode('{in_name:danzorbhsing}'))->assertDontSee($page->name); diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php index de1e025ad..52f9a3ae2 100644 --- a/tests/Entity/EntityTest.php +++ b/tests/Entity/EntityTest.php @@ -1,13 +1,12 @@ 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) @@ -180,7 +162,7 @@ class EntityTest extends BrowserKitTest ->press('Save Book'); $expectedPattern = '/\/books\/my-first-book-[0-9a-zA-Z]{3}/'; - $this->assertRegExp($expectedPattern, $this->currentUri, "Did not land on expected page [$expectedPattern].\n"); + $this->assertMatchesRegularExpression($expectedPattern, $this->currentUri, "Did not land on expected page [$expectedPattern].\n"); $book = Book::where('slug', '=', 'my-first-book')->first(); return $book; @@ -332,34 +314,4 @@ class EntityTest extends BrowserKitTest ->seePageIs($chapter->getUrl()); } - public function test_page_delete_removes_entity_from_its_activity() - { - $page = Page::query()->first(); - - $this->asEditor()->put($page->getUrl(), [ - 'name' => 'My updated page', - 'html' => '

updated content

', - ]); - $page->refresh(); - - $this->seeInDatabase('activities', [ - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), - ]); - - $resp = $this->delete($page->getUrl()); - $resp->assertResponseStatus(302); - - $this->dontSeeInDatabase('activities', [ - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), - ]); - - $this->seeInDatabase('activities', [ - 'extra' => 'My updated page', - 'entity_id' => 0, - 'entity_type' => '', - ]); - } - } diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 5a94adac9..482e82ae6 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -1,9 +1,9 @@ assertDontSee($page->updated_at->diffForHumans()); } + public function test_page_export_does_not_include_user_or_revision_links() + { + $page = Page::first(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee($page->getUrl('/revisions')); + $resp->assertDontSee($page->createdBy->getProfileUrl()); + $resp->assertSee($page->createdBy->name); + } + public function test_page_export_sets_right_data_type_for_svg_embeds() { $page = Page::first(); - $page->html = ''; + Storage::disk('local')->makeDirectory('uploads/images/gallery'); + Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', ''); + $page->html = ''; $page->save(); $this->asEditor(); - $this->mockHttpFetch(''); $resp = $this->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + $resp->assertStatus(200); $resp->assertSee(''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test'); + } + + public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder() + { + $page = Page::first(); + $page->html = '' + .'' + .''; + $storageDisk = Storage::disk('local'); + $storageDisk->makeDirectory('uploads/images/gallery'); + $storageDisk->put('uploads/images/gallery/svg_test.svg', 'good'); + $storageDisk->put('uploads/svg_test.svg', 'bad'); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + + $storageDisk->delete('uploads/images/gallery/svg_test.svg'); + $storageDisk->delete('uploads/svg_test.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg'); + $resp->assertSee('http://localhost/uploads/svg_test.svg'); + $resp->assertSee('src="/uploads/svg_test.svg"'); + } + + public function test_exports_removes_scripts_from_custom_head() + { + $entities = [ + Page::query()->first(), Chapter::query()->first(), Book::query()->first(), + ]; + setting()->put('app-custom-head', ''); + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $resp->assertDontSee('window.donkey'); + $resp->assertDontSee('script'); + $resp->assertSee('.my-test-class { color: red; }'); + } + } + + public function test_page_export_with_deleted_creator_and_updater() + { + $user = $this->getViewer(['name' => 'ExportWizardTheFifth']); + $page = Page::first(); + $page->created_by = $user->id; + $page->updated_by = $user->id; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee('ExportWizardTheFifth'); + + $user->delete(); + $resp = $this->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertDontSee('ExportWizardTheFifth'); + } + +} diff --git a/tests/Entity/MarkdownTest.php b/tests/Entity/MarkdownTest.php index 452b4c07f..5e5fa8a0c 100644 --- a/tests/Entity/MarkdownTest.php +++ b/tests/Entity/MarkdownTest.php @@ -9,7 +9,7 @@ class MarkdownTest extends BrowserKitTest public function setUp(): void { parent::setUp(); - $this->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 99547fd17..6d5200794 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -1,7 +1,7 @@ Click me', + ''); + $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', '