diff --git a/.env.example.complete b/.env.example.complete index 04cd73b90..86a7351c2 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -200,6 +200,7 @@ LDAP_ID_ATTRIBUTE=uid LDAP_EMAIL_ATTRIBUTE=mail LDAP_DISPLAY_NAME_ATTRIBUTE=cn LDAP_FOLLOW_REFERRALS=true +LDAP_DUMP_USER_DETAILS=false # LDAP group sync configuration # Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/ diff --git a/LICENSE b/LICENSE index 080c54b3e..61aeaad8c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2018 Dan Brown and the BookStack Project contributors +Copyright (c) 2020 Dan Brown and the BookStack Project contributors https://github.com/BookStackApp/BookStack/graphs/contributors Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/app/Auth/Access/Guards/LdapSessionGuard.php b/app/Auth/Access/Guards/LdapSessionGuard.php index 3c98140f6..84f54ad29 100644 --- a/app/Auth/Access/Guards/LdapSessionGuard.php +++ b/app/Auth/Access/Guards/LdapSessionGuard.php @@ -44,11 +44,14 @@ class LdapSessionGuard extends ExternalBaseSessionGuard public function validate(array $credentials = []) { $userDetails = $this->ldapService->getUserDetails($credentials['username']); - $this->lastAttempted = $this->provider->retrieveByCredentials([ - 'external_auth_id' => $userDetails['uid'] - ]); - return $this->ldapService->validateUserCredentials($userDetails, $credentials['username'], $credentials['password']); + if (isset($userDetails['uid'])) { + $this->lastAttempted = $this->provider->retrieveByCredentials([ + 'external_auth_id' => $userDetails['uid'] + ]); + } + + return $this->ldapService->validateUserCredentials($userDetails, $credentials['password']); } /** @@ -66,11 +69,15 @@ class LdapSessionGuard extends ExternalBaseSessionGuard { $username = $credentials['username']; $userDetails = $this->ldapService->getUserDetails($username); - $this->lastAttempted = $user = $this->provider->retrieveByCredentials([ - 'external_auth_id' => $userDetails['uid'] - ]); - if (!$this->ldapService->validateUserCredentials($userDetails, $username, $credentials['password'])) { + $user = null; + if (isset($userDetails['uid'])) { + $this->lastAttempted = $user = $this->provider->retrieveByCredentials([ + 'external_auth_id' => $userDetails['uid'] + ]); + } + + if (!$this->ldapService->validateUserCredentials($userDetails, $credentials['password'])) { return false; } diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 07e9f7b64..d37770558 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -1,6 +1,7 @@ getUserResponseProperty($user, 'cn', null); - return [ + $formatted = [ 'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn), 'dn' => $user['dn'], 'email' => $this->getUserResponseProperty($user, $emailAttr, null), ]; + + if ($this->config['dump_user_details']) { + throw new JsonDebugException([ + 'details_from_ldap' => $user, + 'details_bookstack_parsed' => $formatted, + ]); + } + + return $formatted; } /** * Get a property from an LDAP user response fetch. * Handles properties potentially being part of an array. + * If the given key is prefixed with 'BIN;', that indicator will be stripped + * from the key and any fetched values will be converted from binary to hex. */ protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue) { + $isBinary = strpos($propertyKey, 'BIN;') === 0; $propertyKey = strtolower($propertyKey); - if (isset($userDetails[$propertyKey])) { - return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]); + $value = $defaultValue; + + if ($isBinary) { + $propertyKey = substr($propertyKey, strlen('BIN;')); } - return $defaultValue; + if (isset($userDetails[$propertyKey])) { + $value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]); + if ($isBinary) { + $value = bin2hex($value); + } + } + + return $value; } /** * Check if the given credentials are valid for the given user. * @throws LdapException */ - public function validateUserCredentials(array $ldapUserDetails, string $username, string $password): bool + public function validateUserCredentials(?array $ldapUserDetails, string $password): bool { - if ($ldapUserDetails === null) { + if (is_null($ldapUserDetails)) { return false; } diff --git a/app/Config/services.php b/app/Config/services.php index a0bdd078a..fcde621d2 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -118,6 +118,7 @@ return [ 'ldap' => [ 'server' => env('LDAP_SERVER', false), + 'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false), 'dn' => env('LDAP_DN', false), 'pass' => env('LDAP_PASS', false), 'base_dn' => env('LDAP_BASE_DN', false), diff --git a/app/Entities/Book.php b/app/Entities/Book.php index 919f60035..df0d99228 100644 --- a/app/Entities/Book.php +++ b/app/Entities/Book.php @@ -115,7 +115,7 @@ class Book extends Entity implements HasCoverImage { $pages = $this->directPages()->visible()->get(); $chapters = $this->chapters()->visible()->get(); - return $pages->contact($chapters)->sortBy('priority')->sortByDesc('draft'); + return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); } /** diff --git a/app/Entities/ExportService.php b/app/Entities/ExportService.php index 3ec867959..f945dfbe4 100644 --- a/app/Entities/ExportService.php +++ b/app/Entities/ExportService.php @@ -29,8 +29,9 @@ class ExportService public function pageToContainedHtml(Page $page) { $page->html = (new PageContent($page))->render(); - $pageHtml = view('pages/export', [ - 'page' => $page + $pageHtml = view('pages.export', [ + 'page' => $page, + 'format' => 'html', ])->render(); return $this->containHtml($pageHtml); } @@ -45,9 +46,10 @@ class ExportService $pages->each(function ($page) { $page->html = (new PageContent($page))->render(); }); - $html = view('chapters/export', [ + $html = view('chapters.export', [ 'chapter' => $chapter, - 'pages' => $pages + 'pages' => $pages, + 'format' => 'html', ])->render(); return $this->containHtml($html); } @@ -59,9 +61,10 @@ class ExportService public function bookToContainedHtml(Book $book) { $bookTree = (new BookContents($book))->getTree(false, true); - $html = view('books/export', [ + $html = view('books.export', [ 'book' => $book, - 'bookChildren' => $bookTree + 'bookChildren' => $bookTree, + 'format' => 'html', ])->render(); return $this->containHtml($html); } @@ -73,8 +76,9 @@ class ExportService public function pageToPdf(Page $page) { $page->html = (new PageContent($page))->render(); - $html = view('pages/pdf', [ - 'page' => $page + $html = view('pages.export', [ + 'page' => $page, + 'format' => 'pdf', ])->render(); return $this->htmlToPdf($html); } @@ -90,9 +94,10 @@ class ExportService $page->html = (new PageContent($page))->render(); }); - $html = view('chapters/export', [ + $html = view('chapters.export', [ 'chapter' => $chapter, - 'pages' => $pages + 'pages' => $pages, + 'format' => 'pdf', ])->render(); return $this->htmlToPdf($html); @@ -105,9 +110,10 @@ class ExportService public function bookToPdf(Book $book) { $bookTree = (new BookContents($book))->getTree(false, true); - $html = view('books/export', [ + $html = view('books.export', [ 'book' => $book, - 'bookChildren' => $bookTree + 'bookChildren' => $bookTree, + 'format' => 'pdf', ])->render(); return $this->htmlToPdf($html); } diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 23f07f820..7c25e4981 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -76,7 +76,7 @@ class BaseRepo * @throws ImageUploadException * @throws \Exception */ - public function updateCoverImage(HasCoverImage $entity, UploadedFile $coverImage = null, bool $removeImage = false) + public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false) { if ($coverImage) { $this->imageRepo->destroyImage($entity->cover); diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 7fcc80fac..70db0fa65 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -108,7 +108,7 @@ class BookRepo * @throws ImageUploadException * @throws Exception */ - public function updateCoverImage(Book $book, UploadedFile $coverImage = null, bool $removeImage = false) + public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false) { $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); } diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index 03b54f009..25fa97dae 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -123,7 +123,7 @@ class BookshelfRepo * @throws ImageUploadException * @throws Exception */ - public function updateCoverImage(Bookshelf $shelf, UploadedFile $coverImage = null, bool $removeImage = false) + public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false) { $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage); } diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 57e67dc00..c882ca7c3 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -90,7 +90,7 @@ class BookshelfController extends Controller $bookIds = explode(',', $request->get('books', '')); $shelf = $this->bookshelfRepo->create($request->all(), $bookIds); - $this->bookshelfRepo->updateCoverImage($shelf); + $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null)); Activity::add($shelf, 'bookshelf_create'); return redirect($shelf->getUrl()); diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index a5cd7ad6b..a5d57741d 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -109,7 +109,7 @@ class SearchController extends Controller // Page in chapter if ($entity->isA('page') && $entity->chapter) { - $entities = $entity->chapter->visiblePages(); + $entities = $entity->chapter->getVisiblePages(); } // Page in book or chapter diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 892b2d9cf..00dd60ac7 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -122,8 +122,14 @@ class SettingController extends Controller { $this->checkPermission('settings-manage'); - user()->notify(new TestEmail()); - $this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email])); + try { + user()->notify(new TestEmail()); + $this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email])); + } catch (\Exception $exception) { + $errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage(); + $this->showErrorNotification($errorMessage); + } + return redirect('/settings/maintenance#image-cleanup')->withInput(); } diff --git a/readme.md b/readme.md index fb26cede3..5b51b8eab 100644 --- a/readme.md +++ b/readme.md @@ -2,10 +2,11 @@ [![GitHub release](https://img.shields.io/github/release/BookStackApp/BookStack.svg)](https://github.com/BookStackApp/BookStack/releases/latest) [![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE) +[![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack) [![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions) [![Discord](https://img.shields.io/static/v1?label=Chat&message=Discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2) -A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/. +A platform for storing and organising information and documentation. Details for BookStack can be found on the official website at https://www.bookstackapp.com/. * [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation) * [Documentation](https://www.bookstackapp.com/docs) @@ -25,7 +26,7 @@ In regards to development philosophy, BookStack has a relaxed, open & positive a Below is a high-level road map view for BookStack to provide a sense of direction of where the project is going. This can change at any point and does not reflect many features and improvements that will also be included as part of the journey along this road map. For more granular detail of what will be included in upcoming releases you can review the project milestones as defined in the "Release Process" section below. -- **Platform REST API** *(In Design)* +- **Platform REST API** *(Base Implemented, In review and roll-out)* - *A REST API covering, at minimum, control of core content models (Books, Chapters, Pages) for automation and platform extension.* - **Editor Alignment & Review** - *Review the page editors with goal of achieving increased interoperability & feature parity while also considering collaborative editing potential.* diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index b9e3340a8..7818db260 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -593,6 +593,7 @@ class WysiwygEditor { registerEditorShortcuts(editor); let wrap; + let draggedContentEditable; function hasTextContent(node) { return node && !!( node.textContent || node.innerText ); @@ -601,12 +602,19 @@ class WysiwygEditor { editor.on('dragstart', function () { let node = editor.selection.getNode(); - if (node.nodeName !== 'IMG') return; - wrap = editor.dom.getParent(node, '.mceTemp'); + if (node.nodeName === 'IMG') { + wrap = editor.dom.getParent(node, '.mceTemp'); - if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) { - wrap = node.parentNode; + if (!wrap && node.parentNode.nodeName === 'A' && !hasTextContent(node.parentNode)) { + wrap = node.parentNode; + } } + + // Track dragged contenteditable blocks + if (node.hasAttribute('contenteditable') && node.getAttribute('contenteditable') === 'false') { + draggedContentEditable = node; + } + }); editor.on('drop', function (event) { @@ -614,7 +622,7 @@ class WysiwygEditor { rng = tinymce.dom.RangeUtils.getCaretRangeFromPoint(event.clientX, event.clientY, editor.getDoc()); // Template insertion - const templateId = event.dataTransfer.getData('bookstack/template'); + const templateId = event.dataTransfer && event.dataTransfer.getData('bookstack/template'); if (templateId) { event.preventDefault(); window.$http.get(`/templates/${templateId}`).then(resp => { @@ -638,6 +646,22 @@ class WysiwygEditor { }); } + // Handle contenteditable section drop + if (!event.isDefaultPrevented() && draggedContentEditable) { + event.preventDefault(); + editor.undoManager.transact(function () { + const selectedNode = editor.selection.getNode(); + const range = editor.selection.getRng(); + const selectedNodeRoot = selectedNode.closest('body > *'); + if (range.startOffset > (range.startContainer.length / 2)) { + editor.$(selectedNodeRoot).after(draggedContentEditable); + } else { + editor.$(selectedNodeRoot).before(draggedContentEditable); + } + }); + } + + // Handle image insert if (!event.isDefaultPrevented()) { editorPaste(event, editor, context); } diff --git a/resources/js/services/code.js b/resources/js/services/code.js index 718ef5721..a8cede5f4 100644 --- a/resources/js/services/code.js +++ b/resources/js/services/code.js @@ -111,7 +111,7 @@ function highlightWithin(parent) { function highlightElem(elem) { const innerCodeElem = elem.querySelector('code[class^=language-]'); elem.innerHTML = elem.innerHTML.replace(//gi ,'\n'); - const content = elem.textContent; + const content = elem.textContent.trimEnd(); let mode = ''; if (innerCodeElem !== null) { diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 4752d8b0c..38f1ce28a 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -96,4 +96,7 @@ return [ 'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls', 'api_user_token_expired' => 'The authorization token used has expired', + // Settings & Maintenance + 'maintenance_test_email_failure' => 'Error thrown when sending a test email:', + ]; diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index 77e0773eb..d28706781 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -238,7 +238,6 @@ code { padding: 1px 3px; white-space:pre-wrap; line-height: 1.2em; - margin-bottom: 1.2em; } span.code { diff --git a/resources/sass/export-styles.scss b/resources/sass/export-styles.scss index 958b78807..6d9a1a718 100644 --- a/resources/sass/export-styles.scss +++ b/resources/sass/export-styles.scss @@ -5,7 +5,6 @@ @import "text"; @import "layout"; @import "blocks"; -@import "forms"; @import "tables"; @import "header"; @import "lists"; diff --git a/resources/views/books/export.blade.php b/resources/views/books/export.blade.php index 1cf91046d..e86a24e81 100644 --- a/resources/views/books/export.blade.php +++ b/resources/views/books/export.blade.php @@ -4,10 +4,9 @@ {{ $book->name }} + @include('partials.export-styles', ['format' => $format]) + - @yield('head') @include('partials.custom-head') diff --git a/resources/views/pages/export.blade.php b/resources/views/pages/export.blade.php index 4746a56f3..47a4d870a 100644 --- a/resources/views/pages/export.blade.php +++ b/resources/views/pages/export.blade.php @@ -4,12 +4,31 @@ {{ $page->name }} - - @yield('head') + @include('partials.export-styles', ['format' => $format]) + + @if($format === 'pdf') + + @endif + @include('partials.custom-head') diff --git a/resources/views/pages/pdf.blade.php b/resources/views/pages/pdf.blade.php deleted file mode 100644 index 33a009fee..000000000 --- a/resources/views/pages/pdf.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -@extends('pages/export') - -@section('head') - -@stop \ No newline at end of file diff --git a/resources/views/partials/export-styles.blade.php b/resources/views/partials/export-styles.blade.php new file mode 100644 index 000000000..52bfda2a6 --- /dev/null +++ b/resources/views/partials/export-styles.blade.php @@ -0,0 +1,29 @@ + + +@if ($format === 'pdf') + +@endif \ No newline at end of file diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index cb1194e22..f6c5997b3 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -1,5 +1,6 @@ set([ + config()->set([ 'auth.method' => 'ldap', 'auth.defaults.guard' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', @@ -166,7 +167,7 @@ class LdapTest extends BrowserKitTest ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => 'cooluser456']); } - public function test_initial_incorrect_details() + public function test_initial_incorrect_credentials() { $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); $this->mockLdap->shouldReceive('setVersion')->once(); @@ -186,6 +187,23 @@ class LdapTest extends BrowserKitTest ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]); } + 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->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.') + ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]); + } + + public function test_create_user_form() { $this->asAdmin()->visit('/settings/users/create') @@ -543,4 +561,53 @@ class LdapTest extends BrowserKitTest $resp = $this->post('/register'); $this->assertPermissionError($resp); } + + public function test_dump_user_details_option_works() + { + 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->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->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')] + ]]); + $this->mockLdap->shouldReceive('bind')->times(1)->andReturn(true); + $this->mockEscapes(1); + + $this->post('/login', [ + 'username' => $this->mockUser->name, + 'password' => $this->mockUser->password, + ]); + $this->seeJsonStructure([ + 'details_from_ldap' => [], + 'details_bookstack_parsed' => [], + ]); + } + + public function test_ldap_attributes_can_be_binary_decoded_if_marked() + { + 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->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [hex2bin('FFF8F7')], + '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']); + } } diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index 5c7673847..a318ebe24 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -1,14 +1,17 @@ getViewer(); @@ -83,6 +86,26 @@ class BookShelfTest extends TestCase $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]); } + public function test_shelves_create_sets_cover_image() + { + $shelfInfo = [ + 'name' => 'My test book' . Str::random(4), + 'description' => 'Test book description ' . Str::random(10) + ]; + + $imageFile = $this->getTestImage('shelf-test.png'); + $resp = $this->asEditor()->call('POST', '/shelves', $shelfInfo, [], ['image' => $imageFile]); + $resp->assertRedirect(); + + $lastImage = Image::query()->orderByDesc('id')->firstOrFail(); + $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first(); + $this->assertDatabaseHas('bookshelves', [ + 'id' => $shelf->id, + 'image_id' => $lastImage->id, + ]); + $this->assertEquals($lastImage->id, $shelf->cover->id); + } + public function test_shelf_view() { $shelf = Bookshelf::first(); diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 3eb50a412..34c3cd4a8 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -1,6 +1,7 @@ first(); + $book = Book::all()->first(); $page = $book->pages->first(); $search = $this->asEditor()->get('/search?term=' . urlencode($page->name)); @@ -54,7 +55,7 @@ class EntitySearchTest extends TestCase public function test_book_search() { - $book = \BookStack\Entities\Book::first(); + $book = Book::first(); $page = $book->pages->last(); $chapter = $book->chapters->last(); @@ -67,7 +68,7 @@ class EntitySearchTest extends TestCase public function test_chapter_search() { - $chapter = \BookStack\Entities\Chapter::has('pages')->first(); + $chapter = Chapter::has('pages')->first(); $page = $chapter->pages[0]; $pageTestResp = $this->asEditor()->get('/search/chapter/' . $chapter->id . '?term=' . urlencode($page->name)); @@ -77,11 +78,11 @@ class EntitySearchTest extends TestCase public function test_tag_search() { $newTags = [ - new \BookStack\Actions\Tag([ + new Tag([ 'name' => 'animal', 'value' => 'cat' ]), - new \BookStack\Actions\Tag([ + new Tag([ 'name' => 'color', 'value' => 'red' ]) @@ -204,4 +205,75 @@ class EntitySearchTest extends TestCase $chapterSearch->assertSee($chapter->name); $chapterSearch->assertSee($chapter->book->getShortName(42)); } + + public function test_sibling_search_for_pages() + { + $chapter = Chapter::query()->with('pages')->first(); + $this->assertGreaterThan(2, count($chapter->pages), 'Ensure we\'re testing with at least 1 sibling'); + $page = $chapter->pages->first(); + + $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page"); + $search->assertSuccessful(); + foreach ($chapter->pages as $page) { + $search->assertSee($page->name); + } + + $search->assertDontSee($chapter->name); + } + + public function test_sibling_search_for_pages_without_chapter() + { + $page = Page::query()->where('chapter_id', '=', 0)->firstOrFail(); + $bookChildren = $page->book->getDirectChildren(); + $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling'); + + $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$page->id}&entity_type=page"); + $search->assertSuccessful(); + foreach ($bookChildren as $child) { + $search->assertSee($child->name); + } + + $search->assertDontSee($page->book->name); + } + + public function test_sibling_search_for_chapters() + { + $chapter = Chapter::query()->firstOrFail(); + $bookChildren = $chapter->book->getDirectChildren(); + $this->assertGreaterThan(2, count($bookChildren), 'Ensure we\'re testing with at least 1 sibling'); + + $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$chapter->id}&entity_type=chapter"); + $search->assertSuccessful(); + foreach ($bookChildren as $child) { + $search->assertSee($child->name); + } + + $search->assertDontSee($chapter->book->name); + } + + public function test_sibling_search_for_books() + { + $books = Book::query()->take(10)->get(); + $book = $books->first(); + $this->assertGreaterThan(2, count($books), 'Ensure we\'re testing with at least 1 sibling'); + + $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$book->id}&entity_type=book"); + $search->assertSuccessful(); + foreach ($books as $expectedBook) { + $search->assertSee($expectedBook->name); + } + } + + public function test_sibling_search_for_shelves() + { + $shelves = Bookshelf::query()->take(10)->get(); + $shelf = $shelves->first(); + $this->assertGreaterThan(2, count($shelves), 'Ensure we\'re testing with at least 1 sibling'); + + $search = $this->actingAs($this->getViewer())->get("/search/entity/siblings?entity_id={$shelf->id}&entity_type=bookshelf"); + $search->assertSuccessful(); + foreach ($shelves as $expectedShelf) { + $search->assertSee($expectedShelf->name); + } + } } diff --git a/tests/TestEmailTest.php b/tests/TestEmailTest.php index c06311d84..76ff322ff 100644 --- a/tests/TestEmailTest.php +++ b/tests/TestEmailTest.php @@ -1,6 +1,7 @@ mock(Dispatcher::class); + $this->app[Dispatcher::class] = $mockDispatcher; + + $exception = new \Exception('A random error occurred when testing an email'); + $mockDispatcher->shouldReceive('send')->andThrow($exception); + + $admin = $this->getAdmin(); + $sendReq = $this->actingAs($admin)->post('/settings/maintenance/send-test-email'); + $sendReq->assertRedirect('/settings/maintenance#image-cleanup'); + $this->assertSessionHas('error'); + + $message = session()->get('error'); + $this->assertStringContainsString('Error thrown when sending a test email:', $message); + $this->assertStringContainsString('A random error occurred when testing an email', $message); + } + public function test_send_test_email_requires_settings_manage_permission() { Notification::fake();