{{ trans('settings.app_name_desc') }}
{{ trans('settings.app_editor_desc') }}
{!! trans('settings.app_primary_color_desc') !!}
{{ trans('settings.app_homepage_desc') }}
{!! nl2br(e($shelf->description)) !!}
@if(count($shelf->visibleBooks) > 0) -@@ -87,6 +95,8 @@ @endif + @include('partials.view-toggle', ['view' => $view, 'type' => 'shelf']) +
@if(userCan('bookshelf-update', $shelf)) diff --git a/routes/api.php b/routes/api.php index 73f2faf79..1b90d9b8f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,8 +9,28 @@ Route::get('docs', 'ApiDocsController@display'); Route::get('docs.json', 'ApiDocsController@json'); -Route::get('books', 'BooksApiController@list'); -Route::post('books', 'BooksApiController@create'); -Route::get('books/{id}', 'BooksApiController@read'); -Route::put('books/{id}', 'BooksApiController@update'); -Route::delete('books/{id}', 'BooksApiController@delete'); +Route::get('books', 'BookApiController@list'); +Route::post('books', 'BookApiController@create'); +Route::get('books/{id}', 'BookApiController@read'); +Route::put('books/{id}', 'BookApiController@update'); +Route::delete('books/{id}', 'BookApiController@delete'); + +Route::get('books/{id}/export/html', 'BookExportApiController@exportHtml'); +Route::get('books/{id}/export/pdf', 'BookExportApiController@exportPdf'); +Route::get('books/{id}/export/plaintext', 'BookExportApiController@exportPlainText'); + +Route::get('chapters', 'ChapterApiController@list'); +Route::post('chapters', 'ChapterApiController@create'); +Route::get('chapters/{id}', 'ChapterApiController@read'); +Route::put('chapters/{id}', 'ChapterApiController@update'); +Route::delete('chapters/{id}', 'ChapterApiController@delete'); + +Route::get('chapters/{id}/export/html', 'ChapterExportApiController@exportHtml'); +Route::get('chapters/{id}/export/pdf', 'ChapterExportApiController@exportPdf'); +Route::get('chapters/{id}/export/plaintext', 'ChapterExportApiController@exportPlainText'); + +Route::get('shelves', 'BookshelfApiController@list'); +Route::post('shelves', 'BookshelfApiController@create'); +Route::get('shelves/{id}', 'BookshelfApiController@read'); +Route::put('shelves/{id}', 'BookshelfApiController@update'); +Route::delete('shelves/{id}', 'BookshelfApiController@delete'); diff --git a/routes/web.php b/routes/web.php index 90261e1ac..3e05e394d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -178,10 +178,12 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/users', 'UserController@index'); Route::get('/users/create', 'UserController@create'); Route::get('/users/{id}/delete', 'UserController@delete'); - Route::patch('/users/{id}/switch-book-view', 'UserController@switchBookView'); + Route::patch('/users/{id}/switch-books-view', 'UserController@switchBooksView'); + Route::patch('/users/{id}/switch-shelves-view', 'UserController@switchShelvesView'); Route::patch('/users/{id}/switch-shelf-view', 'UserController@switchShelfView'); Route::patch('/users/{id}/change-sort/{type}', 'UserController@changeSort'); Route::patch('/users/{id}/update-expansion-preference/{key}', 'UserController@updateExpansionPreference'); + Route::patch('/users/toggle-dark-mode', 'UserController@toggleDarkMode'); Route::post('/users/create', 'UserController@store'); Route::get('/users/{id}', 'UserController@edit'); Route::put('/users/{id}', 'UserController@update'); diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php index 1f283753a..302093947 100644 --- a/tests/Api/ApiAuthTest.php +++ b/tests/Api/ApiAuthTest.php @@ -1,10 +1,9 @@ -setSettings(['app-public' => true]); + $guest = User::getDefault(); + + $this->startSession(); + $resp = $this->get('/api/docs'); + $resp->assertStatus(403); + + $this->giveUserPermissions($guest, ['access-api']); + + $resp = $this->get('/api/docs'); + $resp->assertStatus(200); + } } \ No newline at end of file diff --git a/tests/Api/ApiListingTest.php b/tests/Api/ApiListingTest.php index 741b9664b..bb4920cc3 100644 --- a/tests/Api/ApiListingTest.php +++ b/tests/Api/ApiListingTest.php @@ -1,8 +1,7 @@ -actingAsApiEditor(); + $bookCount = Book::query()->count(); + $resp = $this->get($this->endpoint . '?count=1'); + $resp->assertJson(['total' => $bookCount ]); + } + + public function test_total_on_results_shows_correctly_when_offset_provided() + { + $this->actingAsApiEditor(); + $bookCount = Book::query()->count(); + $resp = $this->get($this->endpoint . '?count=1&offset=1'); + $resp->assertJson(['total' => $bookCount ]); + } + } \ No newline at end of file diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index a40e4c93b..3fd763ec6 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -1,6 +1,7 @@ -assertStatus(204); $this->assertActivityExists('book_delete'); } + + public function test_export_html_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + } + + public function test_export_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + } + + public function test_export_pdf_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$book->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + } } \ No newline at end of file diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php new file mode 100644 index 000000000..15a44459e --- /dev/null +++ b/tests/Api/ChaptersApiTest.php @@ -0,0 +1,186 @@ +actingAsApiEditor(); + $firstChapter = Chapter::query()->orderBy('id', 'asc')->first(); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $firstChapter->id, + 'name' => $firstChapter->name, + 'slug' => $firstChapter->slug, + 'book_id' => $firstChapter->book->id, + 'priority' => $firstChapter->priority, + ] + ]]); + } + + public function test_create_endpoint() + { + $this->actingAsApiEditor(); + $book = Book::query()->first(); + $details = [ + 'name' => 'My API chapter', + 'description' => 'A chapter created via the API', + 'book_id' => $book->id, + 'tags' => [ + [ + 'name' => 'tagname', + 'value' => 'tagvalue', + ] + ] + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(200); + $newItem = Chapter::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug])); + $this->assertDatabaseHas('tags', [ + 'entity_id' => $newItem->id, + 'entity_type' => $newItem->getMorphClass(), + 'name' => 'tagname', + 'value' => 'tagvalue', + ]); + $resp->assertJsonMissing(['pages' => []]); + $this->assertActivityExists('chapter_create', $newItem); + } + + public function test_chapter_name_needed_to_create() + { + $this->actingAsApiEditor(); + $book = Book::query()->first(); + $details = [ + 'book_id' => $book->id, + 'description' => 'A chapter created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson($this->validationResponse([ + "name" => ["The name field is required."] + ])); + } + + public function test_chapter_book_id_needed_to_create() + { + $this->actingAsApiEditor(); + $details = [ + 'name' => 'My api chapter', + 'description' => 'A chapter created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson($this->validationResponse([ + "book_id" => ["The book id field is required."] + ])); + } + + public function test_read_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->first(); + $page = $chapter->pages()->first(); + + $resp = $this->getJson($this->baseEndpoint . "/{$chapter->id}"); + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $chapter->id, + 'slug' => $chapter->slug, + 'created_by' => [ + 'name' => $chapter->createdBy->name, + ], + 'book_id' => $chapter->book_id, + 'updated_by' => [ + 'name' => $chapter->createdBy->name, + ], + 'pages' => [ + [ + 'id' => $page->id, + 'slug' => $page->slug, + 'name' => $page->name, + ] + ], + ]); + $resp->assertJsonCount($chapter->pages()->count(), 'pages'); + } + + public function test_update_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->first(); + $details = [ + 'name' => 'My updated API chapter', + 'description' => 'A chapter created via the API', + 'tags' => [ + [ + 'name' => 'freshtag', + 'value' => 'freshtagval', + ] + ], + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details); + $chapter->refresh(); + + $resp->assertStatus(200); + $resp->assertJson(array_merge($details, [ + 'id' => $chapter->id, 'slug' => $chapter->slug, 'book_id' => $chapter->book_id + ])); + $this->assertActivityExists('chapter_update', $chapter); + } + + public function test_delete_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->first(); + $resp = $this->deleteJson($this->baseEndpoint . "/{$chapter->id}"); + + $resp->assertStatus(204); + $this->assertActivityExists('chapter_delete'); + } + + public function test_export_html_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/html"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + } + + public function test_export_plain_text_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/plaintext"); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + } + + public function test_export_pdf_endpoint() + { + $this->actingAsApiEditor(); + $chapter = Chapter::visible()->first(); + + $resp = $this->get($this->baseEndpoint . "/{$chapter->id}/export/pdf"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + } +} \ No newline at end of file diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php new file mode 100644 index 000000000..13e44d97d --- /dev/null +++ b/tests/Api/ShelvesApiTest.php @@ -0,0 +1,136 @@ +actingAsApiEditor(); + $firstBookshelf = Bookshelf::query()->orderBy('id', 'asc')->first(); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $firstBookshelf->id, + 'name' => $firstBookshelf->name, + 'slug' => $firstBookshelf->slug, + ] + ]]); + } + + public function test_create_endpoint() + { + $this->actingAsApiEditor(); + $books = Book::query()->take(2)->get(); + + $details = [ + 'name' => 'My API shelf', + 'description' => 'A shelf created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['books' => [$books[0]->id, $books[1]->id]])); + $resp->assertStatus(200); + $newItem = Bookshelf::query()->orderByDesc('id')->where('name', '=', $details['name'])->first(); + $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug])); + $this->assertActivityExists('bookshelf_create', $newItem); + foreach ($books as $index => $book) { + $this->assertDatabaseHas('bookshelves_books', [ + 'bookshelf_id' => $newItem->id, + 'book_id' => $book->id, + 'order' => $index, + ]); + } + } + + public function test_shelf_name_needed_to_create() + { + $this->actingAsApiEditor(); + $details = [ + 'description' => 'A shelf created via the API', + ]; + + $resp = $this->postJson($this->baseEndpoint, $details); + $resp->assertStatus(422); + $resp->assertJson([ + "error" => [ + "message" => "The given data was invalid.", + "validation" => [ + "name" => ["The name field is required."] + ], + "code" => 422, + ], + ]); + } + + public function test_read_endpoint() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + + $resp = $this->getJson($this->baseEndpoint . "/{$shelf->id}"); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $shelf->id, + 'slug' => $shelf->slug, + 'created_by' => [ + 'name' => $shelf->createdBy->name, + ], + 'updated_by' => [ + 'name' => $shelf->createdBy->name, + ] + ]); + } + + public function test_update_endpoint() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + $details = [ + 'name' => 'My updated API shelf', + 'description' => 'A shelf created via the API', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); + $shelf->refresh(); + + $resp->assertStatus(200); + $resp->assertJson(array_merge($details, ['id' => $shelf->id, 'slug' => $shelf->slug])); + $this->assertActivityExists('bookshelf_update', $shelf); + } + + public function test_update_only_assigns_books_if_param_provided() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + $this->assertTrue($shelf->books()->count() > 0); + $details = [ + 'name' => 'My updated API shelf', + ]; + + $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); + $resp->assertStatus(200); + $this->assertTrue($shelf->books()->count() > 0); + + $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", ['books' => []]); + $resp->assertStatus(200); + $this->assertTrue($shelf->books()->count() === 0); + } + + public function test_delete_endpoint() + { + $this->actingAsApiEditor(); + $shelf = Bookshelf::visible()->first(); + $resp = $this->deleteJson($this->baseEndpoint . "/{$shelf->id}"); + + $resp->assertStatus(204); + $this->assertActivityExists('bookshelf_delete'); + } +} \ No newline at end of file diff --git a/tests/TestsApi.php b/tests/Api/TestsApi.php similarity index 67% rename from tests/TestsApi.php rename to tests/Api/TestsApi.php index 0bb10a4cc..1ad4d14b6 100644 --- a/tests/TestsApi.php +++ b/tests/Api/TestsApi.php @@ -1,6 +1,4 @@ - ["code" => $code, "message" => $message]]; } + /** + * Format the given (field_name => ["messages"]) array + * into a standard validation response format. + */ + protected function validationResponse(array $messages): array + { + $err = $this->errorResponse("The given data was invalid.", 422); + $err['error']['validation'] = $messages; + return $err; + } /** * Get an approved API auth header. */ diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index eb83faded..f1f476966 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -1,10 +1,15 @@ -press('Resend Confirmation Email'); // Get confirmation and confirm notification matches - $emailConfirmation = \DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first(); + $emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first(); Notification::assertSentTo($dbUser, ConfirmEmail::class, function($notification, $channels) use ($emailConfirmation) { return $notification->token === $emailConfirmation->token; }); @@ -256,7 +261,7 @@ class AuthTest extends BrowserKitTest ->seePageIs('/settings/users'); $userPassword = User::find($user->id)->password; - $this->assertTrue(\Hash::check('newpassword', $userPassword)); + $this->assertTrue(Hash::check('newpassword', $userPassword)); } public function test_user_deletion() @@ -275,7 +280,7 @@ class AuthTest extends BrowserKitTest public function test_user_cannot_be_deleted_if_last_admin() { - $adminRole = \BookStack\Auth\Role::getRole('admin'); + $adminRole = Role::getRole('admin'); // Delete all but one admin user if there are more than one $adminUsers = $adminRole->users; @@ -308,14 +313,13 @@ class AuthTest extends BrowserKitTest public function test_reset_password_flow() { - Notification::fake(); $this->visit('/login')->click('Forgot Password?') ->seePageIs('/password/email') ->type('admin@admin.com', 'email') ->press('Send Reset Link') - ->see('A password reset link has been sent to admin@admin.com'); + ->see('A password reset link will be sent to admin@admin.com if that email address is found in the system.'); $this->seeInDatabase('password_resets', [ 'email' => 'admin@admin.com' @@ -323,8 +327,8 @@ class AuthTest extends BrowserKitTest $user = User::where('email', '=', 'admin@admin.com')->first(); - Notification::assertSentTo($user, \BookStack\Notifications\ResetPassword::class); - $n = Notification::sent($user, \BookStack\Notifications\ResetPassword::class); + Notification::assertSentTo($user, ResetPassword::class); + $n = Notification::sent($user, ResetPassword::class); $this->visit('/password/reset/' . $n->first()->token) ->see('Reset Password') @@ -336,6 +340,28 @@ class AuthTest extends BrowserKitTest ->see('Your password has been successfully reset'); } + public function test_reset_password_flow_shows_success_message_even_if_wrong_password_to_prevent_user_discovery() + { + $this->visit('/login')->click('Forgot Password?') + ->seePageIs('/password/email') + ->type('barry@admin.com', 'email') + ->press('Send Reset Link') + ->see('A password reset link will be sent to barry@admin.com if that email address is found in the system.') + ->dontSee('We can\'t find a user'); + + + $this->visit('/password/reset/arandometokenvalue') + ->see('Reset Password') + ->submitForm('Reset Password', [ + 'email' => 'barry@admin.com', + 'password' => 'randompass', + 'password_confirmation' => 'randompass' + ])->followRedirects() + ->seePageIs('/password/reset/arandometokenvalue') + ->dontSee('We can\'t find a user') + ->see('The password reset token is invalid for this email address.'); + } + public function test_reset_password_page_shows_sign_links() { $this->setSettings(['registration-enabled' => 'true']); @@ -355,13 +381,30 @@ class AuthTest extends BrowserKitTest ->seePageUrlIs($page->getUrl()); } + public function test_login_authenticates_admins_on_all_guards() + { + $this->post('/login', ['email' => 'admin@admin.com', 'password' => 'password']); + $this->assertTrue(auth()->check()); + $this->assertTrue(auth('ldap')->check()); + $this->assertTrue(auth('saml2')->check()); + } + + public function test_login_authenticates_nonadmins_on_default_guard_only() + { + $editor = $this->getEditor(); + $editor->password = bcrypt('password'); + $editor->save(); + + $this->post('/login', ['email' => $editor->email, 'password' => 'password']); + $this->assertTrue(auth()->check()); + $this->assertFalse(auth('ldap')->check()); + $this->assertFalse(auth('saml2')->check()); + } + /** * Perform a login - * @param string $email - * @param string $password - * @return $this */ - protected function login($email, $password) + protected function login(string $email, string $password): AuthTest { return $this->visit('/login') ->type($email, '#email') diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 324e3041f..ed8748f08 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -1,10 +1,11 @@ -press('Log In'); } + /** + * Set LDAP method mocks for things we commonly call without altering. + */ + protected function commonLdapMocks(int $connects = 1, int $versions = 1, int $options = 2, int $binds = 4, int $escapes = 2, int $explodes = 0) + { + $this->mockLdap->shouldReceive('connect')->times($connects)->andReturn($this->resourceId); + $this->mockLdap->shouldReceive('setVersion')->times($versions); + $this->mockLdap->shouldReceive('setOption')->times($options); + $this->mockLdap->shouldReceive('bind')->times($binds)->andReturn(true); + $this->mockEscapes($escapes); + $this->mockExplodes($explodes); + } + public function test_login() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -74,8 +86,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login')->see('Please enter an email to use for this account.'); @@ -93,9 +103,7 @@ class LdapTest extends BrowserKitTest 'registration-restrict' => 'testing.com' ]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -103,8 +111,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login') @@ -121,10 +127,9 @@ class LdapTest extends BrowserKitTest public function test_login_works_when_no_uid_provided_by_ldap_server() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); - $this->mockLdap->shouldReceive('setOption')->times(1); + + $this->commonLdapMocks(1, 1, 1, 2, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -132,8 +137,6 @@ class LdapTest extends BrowserKitTest 'dn' => $ldapDn, 'mail' => [$this->mockUser->email] ]]); - $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true); - $this->mockEscapes(1); $this->mockUserLogin() ->seePageIs('/') @@ -144,10 +147,9 @@ class LdapTest extends BrowserKitTest public function test_a_custom_uid_attribute_can_be_specified_and_is_used_properly() { config()->set(['services.ldap.id_attribute' => 'my_custom_id']); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); + + $this->commonLdapMocks(1, 1, 1, 2, 1); $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn'); - $this->mockLdap->shouldReceive('setOption')->times(1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -158,9 +160,6 @@ class LdapTest extends BrowserKitTest ]]); - $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true); - $this->mockEscapes(1); - $this->mockUserLogin() ->seePageIs('/') ->see($this->mockUser->name) @@ -169,9 +168,7 @@ class LdapTest extends BrowserKitTest public function test_initial_incorrect_credentials() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 0, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -180,7 +177,6 @@ class LdapTest extends BrowserKitTest 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false); - $this->mockEscapes(1); $this->mockUserLogin() ->seePageIs('/login')->see('These credentials do not match our records.') @@ -189,14 +185,10 @@ class LdapTest extends BrowserKitTest public function test_login_not_found_username() { - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 1, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 0]); - $this->mockLdap->shouldReceive('bind')->times(1)->andReturn(true, false); - $this->mockEscapes(1); $this->mockUserLogin() ->seePageIs('/login')->see('These credentials do not match our records.') @@ -256,9 +248,8 @@ class LdapTest extends BrowserKitTest 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => false, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(4); + + $this->commonLdapMocks(1, 1, 4, 5, 4, 6); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -272,9 +263,6 @@ class LdapTest extends BrowserKitTest 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(5)->andReturn(true); - $this->mockEscapes(4); - $this->mockExplodes(6); $this->mockUserLogin()->seePageIs('/'); @@ -305,9 +293,8 @@ class LdapTest extends BrowserKitTest 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => true, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(3); + + $this->commonLdapMocks(1, 1, 3, 4, 3, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -320,9 +307,6 @@ class LdapTest extends BrowserKitTest 0 => "cn=ldaptester,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(3); - $this->mockExplodes(2); $this->mockUserLogin()->seePageIs('/'); @@ -354,9 +338,8 @@ class LdapTest extends BrowserKitTest 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => true, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(3); + + $this->commonLdapMocks(1, 1, 3, 4, 3, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(3) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -369,9 +352,6 @@ class LdapTest extends BrowserKitTest 0 => "cn=ex-auth-a,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(3); - $this->mockExplodes(2); $this->mockUserLogin()->seePageIs('/'); @@ -399,9 +379,8 @@ class LdapTest extends BrowserKitTest 'services.ldap.group_attribute' => 'memberOf', 'services.ldap.remove_from_groups' => true, ]); - $this->mockLdap->shouldReceive('connect')->times(1)->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->times(1); - $this->mockLdap->shouldReceive('setOption')->times(4); + + $this->commonLdapMocks(1, 1, 4, 5, 4, 6); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -415,9 +394,6 @@ class LdapTest extends BrowserKitTest 1 => "cn=ldaptester-second,ou=groups,dc=example,dc=com", ] ]]); - $this->mockLdap->shouldReceive('bind')->times(5)->andReturn(true); - $this->mockEscapes(4); - $this->mockExplodes(6); $this->mockUserLogin()->seePageIs('/'); @@ -438,9 +414,7 @@ class LdapTest extends BrowserKitTest 'services.ldap.display_name_attribute' => 'displayName' ]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -449,8 +423,6 @@ class LdapTest extends BrowserKitTest 'dn' => ['dc=test' . config('services.ldap.base_dn')], 'displayname' => 'displayNameAttribute' ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login')->see('Please enter an email to use for this account.'); @@ -468,9 +440,7 @@ class LdapTest extends BrowserKitTest 'services.ldap.display_name_attribute' => 'displayName' ]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(2); + $this->commonLdapMocks(1, 1, 2, 4, 2); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -478,8 +448,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(4)->andReturn(true); - $this->mockEscapes(2); $this->mockUserLogin() ->seePageIs('/login')->see('Please enter an email to use for this account.'); @@ -498,15 +466,12 @@ class LdapTest extends BrowserKitTest ]); // Standard mocks - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(0, 1, 1, 2, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true); - $this->mockEscapes(1); $this->mockLdap->shouldReceive('connect')->once() ->with($expectedHost, $expectedPort)->andReturn($this->resourceId); @@ -566,9 +531,7 @@ class LdapTest extends BrowserKitTest { config()->set(['services.ldap.dump_user_details' => true]); - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 1, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) ->andReturn(['count' => 1, 0 => [ @@ -576,8 +539,6 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(1)->andReturn(true); - $this->mockEscapes(1); $this->post('/login', [ 'username' => $this->mockUser->name, @@ -593,10 +554,7 @@ class LdapTest extends BrowserKitTest { config()->set(['services.ldap.id_attribute' => 'BIN;uid']); $ldapService = app()->make(LdapService::class); - - $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); - $this->mockLdap->shouldReceive('setVersion')->once(); - $this->mockLdap->shouldReceive('setOption')->times(1); + $this->commonLdapMocks(1, 1, 1, 1, 1); $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), ['cn', 'dn', 'uid', 'mail', 'cn']) ->andReturn(['count' => 1, 0 => [ @@ -604,10 +562,35 @@ class LdapTest extends BrowserKitTest 'cn' => [$this->mockUser->name], 'dn' => ['dc=test' . config('services.ldap.base_dn')] ]]); - $this->mockLdap->shouldReceive('bind')->times(1)->andReturn(true); - $this->mockEscapes(1); $details = $ldapService->getUserDetails('test'); $this->assertEquals('fff8f7', $details['uid']); } + + public function test_new_ldap_user_login_with_already_used_email_address_shows_error_message_to_user() + { + $this->commonLdapMocks(1, 1, 2, 4, 2); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'mail' => 'tester@example.com', + ]], ['count' => 1, 0 => [ + 'uid' => ['Barry'], + 'cn' => ['Scott'], + 'dn' => ['dc=bscott' . config('services.ldap.base_dn')], + 'mail' => 'tester@example.com', + ]]); + + // First user login + $this->mockUserLogin()->seePageIs('/'); + + // Second user login + auth()->logout(); + $this->post('/login', ['username' => 'bscott', 'password' => 'pass'])->followRedirects(); + + $this->see('A user with the email tester@example.com already exists but with different credentials'); + } } diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 9a3d6d8ec..d0da45297 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -1,7 +1,8 @@ -assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]); $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]); } + + public function test_update_url_command_updates_page_content() + { + $page = Page::query()->first(); + $page->html = ''; + $page->save(); + + $this->artisan('bookstack:update-url https://example.com https://cats.example.com') + ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y') + ->expectsQuestion("This operation could cause issues if used incorrectly. Have you made a backup of your existing database?", 'y'); + + $this->assertDatabaseHas('pages', [ + 'id' => $page->id, + 'html' => '' + ]); + } + + public function test_update_url_command_requires_valid_url() + { + $badUrlMessage = "The given urls are expected to be full urls starting with http:// or https://"; + $this->artisan('bookstack:update-url //example.com https://cats.example.com')->expectsOutput($badUrlMessage); + $this->artisan('bookstack:update-url https://example.com htts://cats.example.com')->expectsOutput($badUrlMessage); + $this->artisan('bookstack:update-url example.com https://cats.example.com')->expectsOutput($badUrlMessage); + + $this->expectException(RuntimeException::class); + $this->artisan('bookstack:update-url https://cats.example.com'); + } + + public function test_regenerate_comment_content_command() + { + Comment::query()->forceCreate([ + 'html' => 'some_old_content', + 'text' => 'some_fresh_content', + ]); + + $this->assertDatabaseHas('comments', [ + 'html' => 'some_old_content', + ]); + + $exitCode = \Artisan::call('bookstack:regenerate-comment-content'); + $this->assertTrue($exitCode === 0, 'Command executed successfully'); + + $this->assertDatabaseMissing('comments', [ + 'html' => 'some_old_content', + ]); + $this->assertDatabaseHas('comments', [ + 'html' => "
some_fresh_content
\n", + ]); + } } diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index a318ebe24..cb3acfb1e 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -1,10 +1,11 @@ -assertElementContains('a', 'New Shelf'); } + public function test_book_not_visible_in_shelf_list_view_if_user_cant_view_shelf() + { + config()->set([ + 'app.views.bookshelves' => 'list', + ]); + $shelf = Bookshelf::query()->first(); + $book = $shelf->books()->first(); + + $resp = $this->asEditor()->get('/shelves'); + $resp->assertSee($book->name); + $resp->assertSee($book->getUrl()); + + $this->setEntityRestrictions($book, []); + + $resp = $this->asEditor()->get('/shelves'); + $resp->assertDontSee($book->name); + $resp->assertDontSee($book->getUrl()); + } + public function test_shelves_create() { $booksToInclude = Book::take(2)->get(); @@ -263,4 +283,32 @@ class BookShelfTest extends TestCase $pageVisit->assertElementNotContains('.breadcrumbs', $shelf->getShortName()); } + public function test_bookshelves_show_on_book() + { + // Create shelf + $shelfInfo = [ + 'name' => 'My test shelf' . Str::random(4), + 'description' => 'Test shelf description ' . Str::random(10) + ]; + + $this->asEditor()->post('/shelves', $shelfInfo); + $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); + + // Create book and add to shelf + $this->asEditor()->post($shelf->getUrl('/create-book'), [ + 'name' => 'Test book name', + 'description' => 'Book in shelf description' + ]); + + $newBook = Book::query()->orderBy('id', 'desc')->first(); + + $resp = $this->asEditor()->get($newBook->getUrl()); + $resp->assertElementContains('.tri-layout-left-contents', $shelfInfo['name']); + + // Remove shelf + $this->delete($shelf->getUrl()); + + $resp = $this->asEditor()->get($newBook->getUrl()); + $resp->assertDontSee($shelfInfo['name']); + } } diff --git a/tests/Entity/CommentSettingTest.php b/tests/Entity/CommentSettingTest.php index 967e550a7..3c8cae68c 100644 --- a/tests/Entity/CommentSettingTest.php +++ b/tests/Entity/CommentSettingTest.php @@ -1,28 +1,35 @@ -page = \BookStack\Entities\Page::first(); - } +class CommentSettingTest extends BrowserKitTest +{ + protected $page; - public function test_comment_disable () { - $this->asAdmin(); + public function setUp(): void + { + parent::setUp(); + $this->page = Page::first(); + } - $this->setSettings(['app-disable-comments' => 'true']); + public function test_comment_disable() + { + $this->asAdmin(); - $this->asAdmin()->visit($this->page->getUrl()) - ->pageNotHasElement('.comments-list'); - } + $this->setSettings(['app-disable-comments' => 'true']); - public function test_comment_enable () { - $this->asAdmin(); + $this->asAdmin()->visit($this->page->getUrl()) + ->pageNotHasElement('.comments-list'); + } - $this->setSettings(['app-disable-comments' => 'false']); + public function test_comment_enable() + { + $this->asAdmin(); - $this->asAdmin()->visit($this->page->getUrl()) - ->pageHasElement('.comments-list'); - } + $this->setSettings(['app-disable-comments' => 'false']); + + $this->asAdmin()->visit($this->page->getUrl()) + ->pageHasElement('.comments-list'); + } } \ No newline at end of file diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php index 2b943f96f..2562f7e7d 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -1,7 +1,8 @@ -putJson("/ajax/comment/$comment->id", [ 'text' => $newText, - 'html' => ''.$newText.'
', ]); $resp->assertStatus(200); @@ -71,4 +71,46 @@ class CommentTest extends TestCase 'id' => $comment->id ]); } + + public function test_comments_converts_markdown_input_to_html() + { + $page = Page::first(); + $this->asAdmin()->postJson("/ajax/page/$page->id/comment", [ + 'text' => '# My Title', + ]); + + $this->assertDatabaseHas('comments', [ + 'entity_id' => $page->id, + 'entity_type' => $page->getMorphClass(), + 'text' => '# My Title', + 'html' => "My Title
\n", + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee('My Title
'); + } + + public function test_html_cannot_be_injected_via_comment_content() + { + $this->asAdmin(); + $page = Page::first(); + + $script = '\n\n# sometextinthecomment'; + $this->postJson("/ajax/page/$page->id/comment", [ + 'text' => $script, + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($script); + $pageView->assertSee('sometextinthecomment'); + + $comment = $page->comments()->first(); + $this->putJson("/ajax/comment/$comment->id", [ + 'text' => $script . 'updated', + ]); + + $pageView = $this->get($page->getUrl()); + $pageView->assertDontSee($script); + $pageView->assertSee('sometextinthecommentupdated'); + } } diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 34c3cd4a8..956e46c37 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -1,10 +1,11 @@ -assertSee($expectedShelf->name); } } + + public function test_search_works_on_updated_page_content() + { + $page = Page::query()->first(); + $this->asEditor(); + + $update = $this->put($page->getUrl(), [ + 'name' => $page->name, + 'html' => 'dog pandabearmonster spaghetti
', + ]); + + $search = $this->asEditor()->get('/search?term=pandabearmonster'); + $search->assertStatus(200); + $search->assertSeeText($page->name); + $search->assertSee($page->getUrl()); + } } diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php index 97684ea4d..d7e4ec61c 100644 --- a/tests/Entity/EntityTest.php +++ b/tests/Entity/EntityTest.php @@ -1,4 +1,4 @@ -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 9a2d32028..5a94adac9 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -1,10 +1,11 @@ -assertSee('new content'); } + public function test_page_revision_preview_shows_content_of_revision() + { + $this->asEditor(); + + $pageRepo = app(PageRepo::class); + $page = Page::first(); + $pageRepo->update($page, ['name' => 'updated page', 'html' => 'new revision content
', 'summary' => 'page revision testing']); + $pageRevision = $page->revisions->last(); + $pageRepo->update($page, ['name' => 'updated page', 'html' => 'Updated content
', 'summary' => 'page revision testing 2']); + + $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id); + $revisionView->assertStatus(200); + $revisionView->assertSee('new revision content'); + } + public function test_page_revision_restore_updates_content() { $this->asEditor(); diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php index 883de4a9f..8eba13557 100644 --- a/tests/Entity/PageTemplateTest.php +++ b/tests/Entity/PageTemplateTest.php @@ -1,4 +1,4 @@ -assertDontSeeText('Log in'); $notFound->assertSeeText('tester'); } + + public function test_item_not_found_does_not_get_logged_to_file() + { + $this->actingAs($this->getViewer()); + $handler = $this->withTestLogger(); + $book = Book::query()->first(); + + // Ensure we're seeing errors + Log::error('cat'); + $this->assertTrue($handler->hasErrorThatContains('cat')); + + $this->get('/books/arandomnotfouindbook'); + $this->get($book->getUrl('/chapter/arandomnotfouindchapter')); + $this->get($book->getUrl('/chapter/arandomnotfouindpages')); + + $this->assertCount(1, $handler->getRecords()); + } } \ No newline at end of file diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php index d7654b867..d5c6e4532 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -11,7 +11,7 @@ class LanguageTest extends TestCase public function setUp(): void { parent::setUp(); - $this->langs = array_diff(scandir(resource_path('lang')), ['..', '.', 'check.php', 'format.php']); + $this->langs = array_diff(scandir(resource_path('lang')), ['..', '.']); } public function test_locales_config_key_set_properly() @@ -22,6 +22,20 @@ class LanguageTest extends TestCase $this->assertEquals(implode(':', $configLocales), implode(':', $this->langs), 'app.locales configuration variable does not match those found in lang files'); } + // Not part of standard phpunit test runs since we sometimes expect non-added langs. + public function do_test_locales_all_have_language_dropdown_entry() + { + $dropdownLocales = array_keys(trans('settings.language_select', [], 'en')); + sort($dropdownLocales); + sort($this->langs); + $diffs = array_diff($this->langs, $dropdownLocales); + if (count($diffs) > 0) { + $diffText = implode(',', $diffs); + $this->addWarning("Languages: {$diffText} found in files but not in language select dropdown."); + } + $this->assertTrue(true); + } + public function test_correct_language_if_not_logged_in() { $loginReq = $this->get('/login'); diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php index d899c6396..7d6c1831a 100644 --- a/tests/Permissions/RestrictionsTest.php +++ b/tests/Permissions/RestrictionsTest.php @@ -1,4 +1,4 @@ -users()->first(); - if (!empty($attributes)) $user->forceFill($attributes)->save(); + if (!empty($attributes)) { + $user->forceFill($attributes)->save(); + } return $user; } @@ -277,4 +280,22 @@ trait SharedTestHelpers $this->assertStringStartsWith('You do not have permission to access', $error); } + /** + * Set a test handler as the logging interface for the application. + * Allows capture of logs for checking against during tests. + */ + protected function withTestLogger(): TestHandler + { + $monolog = new Logger('testing'); + $testHandler = new TestHandler(); + $monolog->pushHandler($testHandler); + + Log::extend('testing', function() use ($monolog) { + return $monolog; + }); + Log::setDefaultDriver('testing'); + + return $testHandler; + } + } \ No newline at end of file diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index c84305ad8..69b737d7d 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -1,4 +1,6 @@ -set('app.url', 'http://example.com/bookstack'); - $this->get('/'); - $this->assertEquals('http://example.com/bookstack', request()->getUri()); - - config()->set('app.url', 'http://example.com/docs/content'); - $this->get('/'); - $this->assertEquals('http://example.com/docs/content', request()->getUri()); - } - public function test_url_helper_takes_custom_url_into_account() { $this->runWithEnv('APP_URL', 'http://example.com/bookstack', function() { diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 12b254d00..e98a90b35 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -1,8 +1,9 @@ -asAdmin(); + $imageName = 'first-image.png'; + + $this->uploadImage($imageName, $page->id); + $image = Image::first(); + $image->type = 'drawio'; + $image->save(); + + $imageGet = $this->getJson("/images/drawio/base64/{$image->id}"); + $imageGet->assertJson([ + 'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=' + ]); + } + + public function test_drawing_base64_upload() + { + $page = Page::first(); + $editor = $this->getEditor(); + $this->actingAs($editor); + + $upload = $this->postJson('images/drawio', [ + 'uploaded_to' => $page->id, + 'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=' + ]); + + $upload->assertStatus(200); + $upload->assertJson([ + 'type' => 'drawio', + 'uploaded_to' => $page->id, + 'created_by' => $editor->id, + 'updated_by' => $editor->id, + ]); + + $image = Image::where('type', '=', 'drawio')->first(); + $this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: '. public_path($image->path)); + + $testImageData = file_get_contents($this->getTestImageFilePath()); + $uploadedImageData = file_get_contents(public_path($image->path)); + $this->assertTrue($testImageData === $uploadedImageData, "Uploaded image file data does not match our test image as expected"); + } + + public function test_drawio_url_can_be_configured() + { + config()->set('services.drawio', 'http://cats.com?dog=tree'); + $page = Page::first(); + $editor = $this->getEditor(); + + $resp = $this->actingAs($editor)->get($page->getUrl('/edit')); + $resp->assertSee('drawio-url="http://cats.com?dog=tree"'); + } + + public function test_drawio_url_can_be_disabled() + { + config()->set('services.drawio', true); + $page = Page::first(); + $editor = $this->getEditor(); + + $resp = $this->actingAs($editor)->get($page->getUrl('/edit')); + $resp->assertSee('drawio-url="https://www.draw.io/?embed=1&proto=json&spin=1"'); + + config()->set('services.drawio', false); + $resp = $this->actingAs($editor)->get($page->getUrl('/edit')); + $resp->assertDontSee('drawio-url'); + } + +} \ No newline at end of file diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 3f6c021a7..416927ac9 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -278,50 +278,6 @@ class ImageTest extends TestCase $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has not been deleted as expected'); } - public function testBase64Get() - { - $page = Page::first(); - $this->asAdmin(); - $imageName = 'first-image.png'; - - $this->uploadImage($imageName, $page->id); - $image = Image::first(); - $image->type = 'drawio'; - $image->save(); - - $imageGet = $this->getJson("/images/drawio/base64/{$image->id}"); - $imageGet->assertJson([ - 'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=' - ]); - } - - public function test_drawing_base64_upload() - { - $page = Page::first(); - $editor = $this->getEditor(); - $this->actingAs($editor); - - $upload = $this->postJson('images/drawio', [ - 'uploaded_to' => $page->id, - 'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=' - ]); - - $upload->assertStatus(200); - $upload->assertJson([ - 'type' => 'drawio', - 'uploaded_to' => $page->id, - 'created_by' => $editor->id, - 'updated_by' => $editor->id, - ]); - - $image = Image::where('type', '=', 'drawio')->first(); - $this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: '. public_path($image->path)); - - $testImageData = file_get_contents($this->getTestImageFilePath()); - $uploadedImageData = file_get_contents(public_path($image->path)); - $this->assertTrue($testImageData === $uploadedImageData, "Uploaded image file data does not match our test image as expected"); - } - protected function getTestProfileImage() { $imageName = 'profile.png'; diff --git a/tests/Uploads/UsesImages.php b/tests/Uploads/UsesImages.php index b24b483d9..251a61c9f 100644 --- a/tests/Uploads/UsesImages.php +++ b/tests/Uploads/UsesImages.php @@ -1,6 +1,5 @@ patch('/settings/users/' . $editor->id.'/update-expansion-preference/my-home-details', ['expand' => 'true']); $invalidKeyRequest->assertStatus(500); } + + public function test_toggle_dark_mode() + { + $home = $this->actingAs($this->getEditor())->get('/'); + $home->assertElementNotExists('.dark-mode'); + $home->assertSee('Dark Mode'); + + $this->assertEquals(false, setting()->getForCurrentUser('dark-mode-enabled', false)); + $prefChange = $this->patch('/settings/users/toggle-dark-mode'); + $prefChange->assertRedirect(); + $this->assertEquals(true, setting()->getForCurrentUser('dark-mode-enabled')); + + $home = $this->actingAs($this->getEditor())->get('/'); + $home->assertElementExists('.dark-mode'); + $home->assertDontSee('Dark Mode'); + $home->assertSee('Light Mode'); + } } \ No newline at end of file diff --git a/tests/User/UserProfileTest.php b/tests/User/UserProfileTest.php index fc1a529ae..0a3a1a6b2 100644 --- a/tests/User/UserProfileTest.php +++ b/tests/User/UserProfileTest.php @@ -1,4 +1,9 @@ -user = \BookStack\Auth\User::all()->last(); + $this->user = User::all()->last(); } public function test_profile_page_shows_name() @@ -55,8 +60,8 @@ class UserProfileTest extends BrowserKitTest $newUser = $this->getNewBlankUser(); $this->actingAs($newUser); $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); - \Activity::add($entities['book'], 'book_update', $entities['book']->id); - \Activity::add($entities['page'], 'page_create', $entities['book']->id); + Activity::add($entities['book'], 'book_update', $entities['book']->id); + Activity::add($entities['page'], 'page_create', $entities['book']->id); $this->asAdmin()->visit('/user/' . $newUser->id) ->seeInElement('#recent-user-activity', 'updated book') @@ -69,8 +74,8 @@ class UserProfileTest extends BrowserKitTest $newUser = $this->getNewBlankUser(); $this->actingAs($newUser); $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); - \Activity::add($entities['book'], 'book_update', $entities['book']->id); - \Activity::add($entities['page'], 'page_create', $entities['book']->id); + Activity::add($entities['book'], 'book_update', $entities['book']->id); + Activity::add($entities['page'], 'page_create', $entities['book']->id); $this->asAdmin()->visit('/')->clickInElement('#recent-activity', $newUser->name) ->seePageIs('/user/' . $newUser->id) @@ -87,7 +92,7 @@ class UserProfileTest extends BrowserKitTest public function test_guest_profile_cannot_be_deleted() { - $guestUser = \BookStack\Auth\User::getDefault(); + $guestUser = User::getDefault(); $this->asAdmin()->visit('/settings/users/' . $guestUser->id . '/delete') ->see('Delete User')->see('Guest') ->press('Confirm') @@ -116,4 +121,24 @@ class UserProfileTest extends BrowserKitTest ->pageHasElement('.featured-image-container'); } + public function test_shelf_view_type_change() + { + $editor = $this->getEditor(); + $shelf = Bookshelf::query()->first(); + setting()->putUser($editor, 'bookshelf_view_type', 'list'); + + $this->actingAs($editor)->visit($shelf->getUrl()) + ->pageNotHasElement('.featured-image-container') + ->pageHasElement('.content-wrap .entity-list-item') + ->see('Grid View'); + + $req = $this->patch("/settings/users/{$editor->id}/switch-shelf-view", ['view_type' => 'grid']); + $req->assertRedirectedTo($shelf->getUrl()); + + $this->actingAs($editor)->visit($shelf->getUrl()) + ->pageHasElement('.featured-image-container') + ->pageNotHasElement('.content-wrap .entity-list-item') + ->see('List View'); + } + } diff --git a/version b/version index d40040377..31c2e1d4b 100644 --- a/version +++ b/version @@ -1 +1 @@ -v0.28-dev +v0.30-dev
'}})}},B=function(e,t){e.addCommand("mceHelp",L(e,t))},N=function(e,t){e.addButton("help",{icon:"help",onclick:L(e,t)}),e.addMenuItem("help",{text:"Help",icon:"help",context:"help",onclick:L(e,t)})};e.add("help",function(e,t){N(e,t),B(e,t),e.shortcuts.add("Alt+0","Open help dialog","mceHelp")})}();
\ No newline at end of file
+!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=function(){},a=function(e){return function(){return e}};function l(r){for(var o=[],e=1;e