From b59e5942c8c2e233752f79ff9668d3646c22c14e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 21 Sep 2018 15:15:16 +0100 Subject: [PATCH] Added testing coverage for Bookshelves Created modified TestResponse so we can use DOM operations in new Testcases as we move away from the BrowserKit tests. --- app/Repos/EntityRepo.php | 1 + resources/lang/en/errors.php | 1 + tests/Entity/BookShelfTest.php | 170 +++++++++++++++++++++++++ tests/Permissions/RestrictionsTest.php | 113 ++++++++++++++++ tests/Permissions/RolesTest.php | 111 ++++++++++++---- tests/SharedTestHelpers.php | 47 +++++++ tests/TestCase.php | 39 +++++- tests/TestResponse.php | 141 ++++++++++++++++++++ 8 files changed, 595 insertions(+), 28 deletions(-) create mode 100644 tests/Entity/BookShelfTest.php create mode 100644 tests/TestResponse.php diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index ccccd95f4..11f89fc34 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -1288,6 +1288,7 @@ class EntityRepo * Returns the number of books that had permissions updated. * @param Bookshelf $bookshelf * @return int + * @throws \Throwable */ public function copyBookshelfPermissions(Bookshelf $bookshelf) { diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index a86a1cdfc..fb09841cf 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -49,6 +49,7 @@ return [ // Entities 'entity_not_found' => 'Entity not found', + 'bookshelf_not_found' => 'Bookshelf not found', 'book_not_found' => 'Book not found', 'page_not_found' => 'Page not found', 'chapter_not_found' => 'Chapter not found', diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php new file mode 100644 index 000000000..9071e3c06 --- /dev/null +++ b/tests/Entity/BookShelfTest.php @@ -0,0 +1,170 @@ +getViewer(); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementContains('header', 'Shelves'); + + $viewer->roles()->delete(); + $this->giveUserPermissions($viewer); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementNotContains('header', 'Shelves'); + + $this->giveUserPermissions($viewer, ['bookshelf-view-all']); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementContains('header', 'Shelves'); + + $viewer->roles()->delete(); + $this->giveUserPermissions($viewer, ['bookshelf-view-own']); + $resp = $this->actingAs($viewer)->get('/'); + $resp->assertElementContains('header', 'Shelves'); + } + + public function test_shelves_page_contains_create_link() + { + $resp = $this->asEditor()->get('/shelves'); + $resp->assertElementContains('a', 'Create New Shelf'); + } + + public function test_shelves_create() + { + $booksToInclude = Book::take(2)->get(); + $shelfInfo = [ + 'name' => 'My test book' . str_random(4), + 'description' => 'Test book description ' . str_random(10) + ]; + $resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [ + 'books' => $booksToInclude->implode('id', ','), + 'tags' => [ + [ + 'name' => 'Test Category', + 'value' => 'Test Tag Value', + ] + ], + ])); + $resp->assertRedirect(); + $editorId = $this->getEditor()->id; + $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); + + $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); + $shelfPage = $this->get($shelf->getUrl()); + $shelfPage->assertSee($shelfInfo['name']); + $shelfPage->assertSee($shelfInfo['description']); + $shelfPage->assertElementContains('.tag-item', 'Test Category'); + $shelfPage->assertElementContains('.tag-item', 'Test Tag Value'); + + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]); + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]); + } + + public function test_shelf_view() + { + $shelf = Bookshelf::first(); + $resp = $this->asEditor()->get($shelf->getUrl()); + $resp->assertStatus(200); + $resp->assertSeeText($shelf->name); + $resp->assertSeeText($shelf->description); + + foreach ($shelf->books as $book) { + $resp->assertSee($book->name); + } + } + + public function test_shelf_view_shows_action_buttons() + { + $shelf = Bookshelf::first(); + $resp = $this->asAdmin()->get($shelf->getUrl()); + $resp->assertSee($shelf->getUrl('/edit')); + $resp->assertSee($shelf->getUrl('/permissions')); + $resp->assertSee($shelf->getUrl('/delete')); + $resp->assertElementContains('a', 'Edit'); + $resp->assertElementContains('a', 'Permissions'); + $resp->assertElementContains('a', 'Delete'); + + $resp = $this->asEditor()->get($shelf->getUrl()); + $resp->assertDontSee($shelf->getUrl('/permissions')); + } + + public function test_shelf_edit() + { + $shelf = Bookshelf::first(); + $resp = $this->asEditor()->get($shelf->getUrl('/edit')); + $resp->assertSeeText('Edit Bookshelf'); + + $booksToInclude = Book::take(2)->get(); + $shelfInfo = [ + 'name' => 'My test book' . str_random(4), + 'description' => 'Test book description ' . str_random(10) + ]; + + $resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [ + 'books' => $booksToInclude->implode('id', ','), + 'tags' => [ + [ + 'name' => 'Test Category', + 'value' => 'Test Tag Value', + ] + ], + ])); + $shelf = Bookshelf::find($shelf->id); + $resp->assertRedirect($shelf->getUrl()); + $this->assertSessionHas('success'); + + $editorId = $this->getEditor()->id; + $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); + + $shelfPage = $this->get($shelf->getUrl()); + $shelfPage->assertSee($shelfInfo['name']); + $shelfPage->assertSee($shelfInfo['description']); + $shelfPage->assertElementContains('.tag-item', 'Test Category'); + $shelfPage->assertElementContains('.tag-item', 'Test Tag Value'); + + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]); + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]); + } + + 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'); + } + + public function test_shelf_copy_permissions() + { + $shelf = Bookshelf::first(); + $resp = $this->asAdmin()->get($shelf->getUrl('/permissions')); + $resp->assertSeeText('Copy Permissions'); + $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\""); + + $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]); + $resp = $this->post($shelf->getUrl('/copy-permissions')); + $child = $shelf->books()->first(); + + $resp->assertRedirect($shelf->getUrl()); + $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]); + } + +} diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php index 2bbb1a5fa..540125fd1 100644 --- a/tests/Permissions/RestrictionsTest.php +++ b/tests/Permissions/RestrictionsTest.php @@ -1,6 +1,7 @@ actingAs($this->user) + ->visit($shelf->getUrl()) + ->seePageIs($shelf->getUrl()); + + $this->setEntityRestrictions($shelf, []); + + $this->forceVisit($shelf->getUrl()) + ->see('Bookshelf not found'); + + $this->setEntityRestrictions($shelf, ['view']); + + $this->visit($shelf->getUrl()) + ->see($shelf->name); + } + + public function test_bookshelf_update_restriction() + { + $shelf = BookShelf::first(); + + $this->actingAs($this->user) + ->visit($shelf->getUrl('/edit')) + ->see('Edit Book'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->forceVisit($shelf->getUrl('/edit')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->visit($shelf->getUrl('/edit')) + ->seePageIs($shelf->getUrl('/edit')); + } + + public function test_bookshelf_delete_restriction() + { + $shelf = Book::first(); + + $this->actingAs($this->user) + ->visit($shelf->getUrl('/delete')) + ->see('Delete Book'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->forceVisit($shelf->getUrl('/delete')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->visit($shelf->getUrl('/delete')) + ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book'); + } + public function test_book_view_restriction() { $book = Book::first(); @@ -325,6 +383,23 @@ class RestrictionsTest extends BrowserKitTest ->seePageIs($pageUrl . '/delete')->see('Delete Page'); } + public function test_bookshelf_restriction_form() + { + $shelf = Bookshelf::first(); + $this->asAdmin()->visit($shelf->getUrl('/permissions')) + ->see('Bookshelf Permissions') + ->check('restricted') + ->check('restrictions[2][view]') + ->press('Save Permissions') + ->seeInDatabase('bookshelves', ['id' => $shelf->id, 'restricted' => true]) + ->seeInDatabase('entity_permissions', [ + 'restrictable_id' => $shelf->id, + 'restrictable_type' => 'BookStack\Bookshelf', + 'role_id' => '2', + 'action' => 'view' + ]); + } + public function test_book_restriction_form() { $book = Book::first(); @@ -413,6 +488,44 @@ class RestrictionsTest extends BrowserKitTest ->dontSee($page->name); } + public function test_bookshelf_update_restriction_override() + { + $shelf = Bookshelf::first(); + + $this->actingAs($this->viewer) + ->visit($shelf->getUrl('/edit')) + ->dontSee('Edit Book'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->forceVisit($shelf->getUrl('/edit')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->visit($shelf->getUrl('/edit')) + ->seePageIs($shelf->getUrl('/edit')); + } + + public function test_bookshelf_delete_restriction_override() + { + $shelf = Bookshelf::first(); + + $this->actingAs($this->viewer) + ->visit($shelf->getUrl('/delete')) + ->dontSee('Delete Book'); + + $this->setEntityRestrictions($shelf, ['view', 'update']); + + $this->forceVisit($shelf->getUrl('/delete')) + ->see('You do not have permission')->seePageIs('/'); + + $this->setEntityRestrictions($shelf, ['view', 'delete']); + + $this->visit($shelf->getUrl('/delete')) + ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book'); + } + public function test_book_create_restriction_override() { $book = Book::first(); diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index f076e6734..e0f827d02 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -1,5 +1,6 @@ user = $this->getViewer(); } - /** - * Give the given user some permissions. - * @param \BookStack\User $user - * @param array $permissions - */ - protected function giveUserPermissions(\BookStack\User $user, $permissions = []) - { - $newRole = $this->createNewRole($permissions); - $user->attachRole($newRole); - $user->load('roles'); - $user->permissions(false); - } - - /** - * Create a new basic role for testing purposes. - * @param array $permissions - * @return Role - */ - protected function createNewRole($permissions = []) - { - $permissionRepo = app(PermissionsRepo::class); - $roleData = factory(\BookStack\Role::class)->make()->toArray(); - $roleData['permissions'] = array_flip($permissions); - return $permissionRepo->saveNewRole($roleData); - } - public function test_admin_can_see_settings() { $this->asAdmin()->visit('/settings')->see('Settings'); @@ -203,6 +178,90 @@ class RolesTest extends BrowserKitTest } } + public function test_bookshelves_create_all_permissions() + { + $this->checkAccessPermission('bookshelf-create-all', [ + '/create-shelf' + ], [ + '/shelves' => 'Create New Shelf' + ]); + + $this->visit('/create-shelf') + ->type('test shelf', 'name') + ->type('shelf desc', 'description') + ->press('Save Shelf') + ->seePageIs('/shelves/test-shelf'); + } + + public function test_bookshelves_edit_own_permission() + { + $otherShelf = Bookshelf::first(); + $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); + $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); + $this->regenEntityPermissions($ownShelf); + + $this->checkAccessPermission('bookshelf-update-own', [ + $ownShelf->getUrl('/edit') + ], [ + $ownShelf->getUrl() => 'Edit' + ]); + + $this->visit($otherShelf->getUrl()) + ->dontSeeInElement('.action-buttons', 'Edit') + ->visit($otherShelf->getUrl('/edit')) + ->seePageIs('/'); + } + + public function test_bookshelves_edit_all_permission() + { + $otherShelf = \BookStack\Bookshelf::first(); + $this->checkAccessPermission('bookshelf-update-all', [ + $otherShelf->getUrl('/edit') + ], [ + $otherShelf->getUrl() => 'Edit' + ]); + } + + public function test_bookshelves_delete_own_permission() + { + $this->giveUserPermissions($this->user, ['bookshelf-update-all']); + $otherShelf = \BookStack\Bookshelf::first(); + $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']); + $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save(); + $this->regenEntityPermissions($ownShelf); + + $this->checkAccessPermission('bookshelf-delete-own', [ + $ownShelf->getUrl('/delete') + ], [ + $ownShelf->getUrl() => 'Delete' + ]); + + $this->visit($otherShelf->getUrl()) + ->dontSeeInElement('.action-buttons', 'Delete') + ->visit($otherShelf->getUrl('/delete')) + ->seePageIs('/'); + $this->visit($ownShelf->getUrl())->visit($ownShelf->getUrl('/delete')) + ->press('Confirm') + ->seePageIs('/shelves') + ->dontSee($ownShelf->name); + } + + public function test_bookshelves_delete_all_permission() + { + $this->giveUserPermissions($this->user, ['bookshelf-update-all']); + $otherShelf = \BookStack\Bookshelf::first(); + $this->checkAccessPermission('bookshelf-delete-all', [ + $otherShelf->getUrl('/delete') + ], [ + $otherShelf->getUrl() => 'Delete' + ]); + + $this->visit($otherShelf->getUrl())->visit($otherShelf->getUrl('/delete')) + ->press('Confirm') + ->seePageIs('/shelves') + ->dontSee($otherShelf->name); + } + public function test_books_create_all_permissions() { $this->checkAccessPermission('book-create-all', [ diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index 325979e74..581dac5f1 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -1,9 +1,11 @@ app[PermissionService::class]->buildJointPermissionsForEntity($entity); + $entity->load('jointPermissions'); + } + + /** + * Create and return a new bookshelf. + * @param array $input + * @return Bookshelf + */ + public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) { + return $this->app[EntityRepo::class]->createFromInput('bookshelf', $input, false); + } + /** * Create and return a new book. * @param array $input @@ -140,4 +161,30 @@ trait SharedTestHelpers $entity->load('jointPermissions'); } + /** + * Give the given user some permissions. + * @param \BookStack\User $user + * @param array $permissions + */ + protected function giveUserPermissions(\BookStack\User $user, $permissions = []) + { + $newRole = $this->createNewRole($permissions); + $user->attachRole($newRole); + $user->load('roles'); + $user->permissions(false); + } + + /** + * Create a new basic role for testing purposes. + * @param array $permissions + * @return Role + */ + protected function createNewRole($permissions = []) + { + $permissionRepo = app(PermissionsRepo::class); + $roleData = factory(Role::class)->make()->toArray(); + $roleData['permissions'] = array_flip($permissions); + return $permissionRepo->saveNewRole($roleData); + } + } \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index e0f160eed..939a1a91e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,13 +2,13 @@ use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -use Illuminate\Foundation\Testing\TestResponse; abstract class TestCase extends BaseTestCase { use CreatesApplication; use DatabaseTransactions; use SharedTestHelpers; + /** * The base URL to use while testing the application. * @var string @@ -18,11 +18,46 @@ abstract class TestCase extends BaseTestCase /** * Assert a permission error has occurred. * @param TestResponse $response + * @return TestCase */ protected function assertPermissionError(TestResponse $response) { $response->assertRedirect('/'); - $this->assertTrue(session()->has('error')); + $this->assertSessionHas('error'); session()->remove('error'); + return $this; + } + + /** + * Assert the session contains a specific entry. + * @param string $key + * @return $this + */ + protected function assertSessionHas(string $key) + { + $this->assertTrue(session()->has($key), "Session does not contain a [{$key}] entry"); + return $this; + } + + /** + * Override of the get method so we can get visibility of custom TestResponse methods. + * @param string $uri + * @param array $headers + * @return TestResponse + */ + public function get($uri, array $headers = []) + { + return parent::get($uri, $headers); + } + + /** + * Create the test response instance from the given response. + * + * @param \Illuminate\Http\Response $response + * @return TestResponse + */ + protected function createTestResponse($response) + { + return TestResponse::fromBaseResponse($response); } } \ No newline at end of file diff --git a/tests/TestResponse.php b/tests/TestResponse.php new file mode 100644 index 000000000..a68a5783f --- /dev/null +++ b/tests/TestResponse.php @@ -0,0 +1,141 @@ +crawlerInstance)) { + $this->crawlerInstance = new Crawler($this->getContent()); + } + return $this->crawlerInstance; + } + + /** + * Assert the response contains the specified element. + * @param string $selector + * @return $this + */ + public function assertElementExists(string $selector) + { + $elements = $this->crawler()->filter($selector); + PHPUnit::assertTrue( + $elements->count() > 0, + 'Unable to find element matching the selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + return $this; + } + + /** + * Assert the response does not contain the specified element. + * @param string $selector + * @return $this + */ + public function assertElementNotExists(string $selector) + { + $elements = $this->crawler()->filter($selector); + PHPUnit::assertTrue( + $elements->count() === 0, + 'Found elements matching the selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + return $this; + } + + /** + * Assert the response includes a specific element containing the given text. + * @param string $selector + * @param string $text + * @return $this + */ + public function assertElementContains(string $selector, string $text) + { + $elements = $this->crawler()->filter($selector); + $matched = false; + $pattern = $this->getEscapedPattern($text); + foreach ($elements as $element) { + $element = new Crawler($element); + if (preg_match("/$pattern/i", $element->html())) { + $matched = true; + break; + } + } + + PHPUnit::assertTrue( + $matched, + 'Unable to find element of selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'containing text'.PHP_EOL.PHP_EOL. + "[{$text}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + + return $this; + } + + /** + * Assert the response does not include a specific element containing the given text. + * @param string $selector + * @param string $text + * @return $this + */ + public function assertElementNotContains(string $selector, string $text) + { + $elements = $this->crawler()->filter($selector); + $matched = false; + $pattern = $this->getEscapedPattern($text); + foreach ($elements as $element) { + $element = new Crawler($element); + if (preg_match("/$pattern/i", $element->html())) { + $matched = true; + break; + } + } + + PHPUnit::assertTrue( + !$matched, + 'Found element of selector: '.PHP_EOL.PHP_EOL. + "[{$selector}]".PHP_EOL.PHP_EOL. + 'containing text'.PHP_EOL.PHP_EOL. + "[{$text}]".PHP_EOL.PHP_EOL. + 'within'.PHP_EOL.PHP_EOL. + "[{$this->getContent()}]." + ); + + return $this; + } + + /** + * Get the escaped text pattern for the constraint. + * @param string $text + * @return string + */ + protected function getEscapedPattern($text) + { + $rawPattern = preg_quote($text, '/'); + $escapedPattern = preg_quote(e($text), '/'); + return $rawPattern == $escapedPattern + ? $rawPattern : "({$rawPattern}|{$escapedPattern})"; + } + +}