diff --git a/.env.example b/.env.example index 4dee3b33441..13114b8b076 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,13 @@ DB_DATABASE=database_database DB_USERNAME=database_username DB_PASSWORD=database_user_password +# Storage system to use +# By default files are stored on the local filesystem, with images being placed in +# public web space so they can be efficiently served directly by the web-server. +# For other options with different security levels & considerations, refer to: +# https://www.bookstackapp.com/docs/admin/upload-config/ +STORAGE_TYPE=local + # Mail system to use # Can be 'smtp' or 'sendmail' MAIL_DRIVER=smtp diff --git a/.env.example.complete b/.env.example.complete index 25687aaac38..ebebaf9e3e8 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -36,10 +36,14 @@ APP_LANG=en # APP_LANG will be used if such a header is not provided. APP_AUTO_LANG_PUBLIC=true -# Application timezone -# Used where dates are displayed such as on exported content. +# Application timezones +# The first option is used to determine what timezone is used for date storage. +# Leaving that as "UTC" is advised. +# The second option is used to set the timezone which will be used for date +# formatting and display. This defaults to the "APP_TIMEZONE" value. # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php APP_TIMEZONE=UTC +APP_DISPLAY_TIMEZONE=UTC # Application theme # Used to specific a themes/ folder where BookStack UI @@ -347,10 +351,25 @@ EXPORT_PDF_COMMAND_TIMEOUT=15 # Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections. WKHTMLTOPDF=false -# Allow

scriptincommentest

', - 'entity_type' => 'page', 'entity_id' => $page + 'commentable_type' => 'page', 'commentable_id' => $page ]); $resp = $this->asAdmin()->get($page->getUrl()); @@ -237,8 +236,8 @@ public function test_comment_html_is_limited() $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); @@ -260,8 +259,8 @@ public function test_comment_html_spans_are_cleaned() $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]); $resp->assertOk(); $this->assertDatabaseHas('comments', [ - 'entity_type' => 'page', - 'entity_id' => $page->id, + 'commentable_type' => 'page', + 'commentable_id' => $page->id, 'html' => $expected, ]); diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php new file mode 100644 index 00000000000..51009da3952 --- /dev/null +++ b/tests/Activity/CommentsApiTest.php @@ -0,0 +1,250 @@ +users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll, Permission::CommentUpdateAll]); + + $page = $this->entities->page(); + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->actingAsForApi($user); + + $actions = [ + ['GET', '/api/comments'], + ['GET', "/api/comments/{$comment->id}"], + ['POST', "/api/comments"], + ['PUT', "/api/comments/{$comment->id}"], + ['DELETE', "/api/comments/{$comment->id}"], + ]; + + foreach ($actions as [$method, $endpoint]) { + $resp = $this->call($method, $endpoint); + $this->assertNotPermissionError($resp); + } + + $comment = Comment::factory()->make(); + $page->comments()->save($comment); + $this->getJson("/api/comments")->assertSee(['id' => $comment->id]); + + $this->permissions->removeUserRolePermissions($user, [ + Permission::CommentDeleteAll, Permission::CommentDeleteOwn, + Permission::CommentUpdateAll, Permission::CommentUpdateOwn, + Permission::CommentCreateAll + ]); + + $this->assertPermissionError($this->json('delete', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('put', "/api/comments/{$comment->id}")); + $this->assertPermissionError($this->json('post', "/api/comments")); + $this->assertNotPermissionError($this->json('get', "/api/comments/{$comment->id}")); + + $this->permissions->disableEntityInheritedPermissions($page); + $this->json('get', "/api/comments/{$comment->id}")->assertStatus(404); + $this->getJson("/api/comments")->assertDontSee(['id' => $comment->id]); + } + + public function test_index() + { + $page = $this->entities->page(); + Comment::query()->delete(); + + $comments = Comment::factory()->count(10)->make(); + $page->comments()->saveMany($comments); + + $firstComment = $comments->first(); + $resp = $this->actingAsApiEditor()->getJson('/api/comments'); + $resp->assertJson([ + 'data' => [ + [ + 'id' => $firstComment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'parent_id' => null, + 'local_id' => $firstComment->local_id, + ], + ], + ]); + $resp->assertJsonCount(10, 'data'); + $resp->assertJson(['total' => 10]); + + $filtered = $this->getJson("/api/comments?filter[id]={$firstComment->id}"); + $filtered->assertJsonCount(1, 'data'); + $filtered->assertJson(['total' => 1]); + } + + public function test_create() + { + $page = $this->entities->page(); + + $resp = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful comment

', + 'content_ref' => 'test-content-ref', + ]); + $resp->assertOk(); + $id = $resp->json('id'); + + $this->assertDatabaseHas('comments', [ + 'id' => $id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful comment

', + ]); + + $comment = Comment::query()->findOrFail($id); + $this->assertIsInt($comment->local_id); + + $reply = $this->actingAsApiEditor()->postJson('/api/comments', [ + 'page_id' => $page->id, + 'html' => '

My wonderful reply

', + 'content_ref' => 'test-content-ref', + 'reply_to' => $comment->local_id, + ]); + $reply->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $reply->json('id'), + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

My wonderful reply

', + 'parent_id' => $comment->local_id, + ]); + } + + public function test_read() + { + $page = $this->entities->page(); + $user = $this->users->viewer(); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $user->id, + 'updated_by' => $user->id, + ]); + $page->comments()->save($comment); + $comment->refresh(); + $reply = Comment::factory()->make([ + 'parent_id' => $comment->local_id, + 'html' => '

A lovelyreply

', + ]); + $page->comments()->save($reply); + + $resp = $this->actingAsApiEditor()->getJson("/api/comments/{$comment->id}"); + $resp->assertJson([ + 'id' => $comment->id, + 'commentable_id' => $page->id, + 'commentable_type' => 'page', + 'html' => '

A lovely comment

', + 'archived' => false, + 'created_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'updated_by' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'replies' => [ + [ + 'id' => $reply->id, + 'html' => '

A lovelyreply

' + ] + ] + ]); + } + + public function test_update() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => null, + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'html' => '

A lovely updated comment

', + ])->assertOk(); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 0, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated comment

', + 'archived' => 1, + ]); + + $this->putJson("/api/comments/{$comment->id}", [ + 'archived' => false, + 'html' => '

A lovely updated again comment

', + ]); + + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'html' => '

A lovely updated again comment

', + 'archived' => 0, + ]); + } + + public function test_update_cannot_archive_replies() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + 'created_by' => $this->users->viewer()->id, + 'updated_by' => $this->users->viewer()->id, + 'parent_id' => 90, + ]); + $page->comments()->save($comment); + + $resp = $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [ + 'archived' => true, + ]); + + $this->assertEquals($this->errorResponse('Only top-level comments can be archived.', 400), $resp->json()); + $this->assertDatabaseHas('comments', [ + 'id' => $comment->id, + 'archived' => 0, + ]); + } + + public function test_destroy() + { + $page = $this->entities->page(); + $user = $this->users->editor(); + $this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll]); + $comment = Comment::factory()->make([ + 'html' => '

A lovely comment

', + ]); + $page->comments()->save($comment); + + $this->actingAsForApi($user)->deleteJson("/api/comments/{$comment->id}")->assertStatus(204); + $this->assertDatabaseMissing('comments', [ + 'id' => $comment->id, + ]); + } +} diff --git a/tests/Activity/MentionParserTest.php b/tests/Activity/MentionParserTest.php new file mode 100644 index 00000000000..08bfc10d2a8 --- /dev/null +++ b/tests/Activity/MentionParserTest.php @@ -0,0 +1,43 @@ +Hello @User

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([5], $result); + + // Test multiple mentions + $html = '

@Alice and @Bob

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([1, 2], $result); + + // Test filtering out invalid IDs (zero and negative) + $html = '

@Invalid @Negative @Valid

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([3], $result); + + // Test non-mention links are ignored + $html = '

Normal Link @User

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([7], $result); + + // Test empty HTML + $result = $parser->parseUserIdsFromHtml(''); + $this->assertEquals([], $result); + + // Test duplicate user IDs + $html = '

@User mentioned @User again

'; + $result = $parser->parseUserIdsFromHtml($html); + $this->assertEquals([4], $result); + } +} diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index c405b07aed7..8be09f890dc 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -327,6 +327,24 @@ public function test_notify_watch_parent_book_new() }); } + public function test_notify_watch_page_ignore_when_no_page_owner() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $entities['page']->owned_by = null; + $entities['page']->save(); + + $watches = new UserEntityWatchOptions($editor, $entities['page']); + $watches->updateLevelByValue(WatchLevels::IGNORE); + + $notifications = Notification::fake(); + $this->asAdmin(); + + $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']); + + $notifications->assertNothingSent(); + } + public function test_notifications_sent_in_right_language() { $editor = $this->users->editor(); @@ -340,8 +358,8 @@ public function test_notifications_sent_in_right_language() ActivityType::PAGE_CREATE => $entities['page'], ActivityType::PAGE_UPDATE => $entities['page'], ActivityType::COMMENT_CREATE => Comment::factory()->make([ - 'entity_id' => $entities['page']->id, - 'entity_type' => $entities['page']->getMorphClass(), + 'commentable_id' => $entities['page']->id, + 'commentable_type' => $entities['page']->getMorphClass(), ]), ]; diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php index 93e4b02e423..76c2c9ce932 100644 --- a/tests/Api/ApiAuthTest.php +++ b/tests/Api/ApiAuthTest.php @@ -12,7 +12,7 @@ class ApiAuthTest extends TestCase { use TestsApi; - protected $endpoint = '/api/books'; + protected string $endpoint = '/api/books'; public function test_requests_succeed_with_default_auth() { @@ -24,7 +24,8 @@ public function test_requests_succeed_with_default_auth() $this->actingAs($viewer, 'standard'); - $resp = $this->get($this->endpoint); + $this->startSession(); + $resp = $this->withCredentials()->get($this->endpoint); $resp->assertStatus(200); } @@ -75,6 +76,7 @@ public function test_api_access_permission_required_to_access_api_with_session_a { $editor = $this->users->editor(); $this->actingAs($editor, 'standard'); + $this->startSession(); $resp = $this->get($this->endpoint); $resp->assertStatus(200); @@ -112,6 +114,28 @@ public function test_access_prevented_for_guest_users_with_api_permission_while_ $resp->assertStatus(200); } + public function test_only_get_requests_are_supported_with_session_auth() + { + $user = $this->users->admin(); + $this->actingAs($user, 'standard'); + $this->startSession(); + + $uriByMethods = [ + 'POST' => '/books', + 'PUT' => '/books/1', + 'DELETE' => '/books/1', + 'HEAD' => '/books', + ]; + + foreach ($uriByMethods as $method => $uri) { + $resp = $this->withCredentials()->json($method, "/api{$uri}"); + $resp->assertStatus(403); + if ($method !== 'HEAD') { + $resp->assertJson($this->errorResponse('Only GET requests are allowed when using the API with cookie-based authentication', 403)); + } + } + } + public function test_token_expiry_checked() { $editor = $this->users->editor(); diff --git a/tests/Api/ApiDocsTest.php b/tests/Api/ApiDocsTest.php index a1603e0ef82..bdf753e87f7 100644 --- a/tests/Api/ApiDocsTest.php +++ b/tests/Api/ApiDocsTest.php @@ -22,7 +22,7 @@ public function test_docs_page_returns_view_with_docs_content() $resp->assertStatus(200); $resp->assertSee(url('/api/docs.json')); $resp->assertSee('Show a JSON view of the API docs data.'); - $resp->assertHeader('Content-Type', 'text/html; charset=UTF-8'); + $resp->assertHeader('Content-Type', 'text/html; charset=utf-8'); } public function test_docs_json_endpoint_returns_json() diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 22ccfb482c9..74f558f381b 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -5,7 +5,6 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; -use Illuminate\Support\Facades\DB; use Tests\TestCase; class BooksApiTest extends TestCase @@ -47,8 +46,8 @@ public function test_index_endpoint_includes_cover_if_set() [ 'id' => $book->id, 'cover' => [ - 'id' => $book->cover->id, - 'url' => $book->cover->url, + 'id' => $book->coverInfo()->getImage()->id, + 'url' => $book->coverInfo()->getImage()->url, ], ], ]]); @@ -94,7 +93,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('books', $expectedDetails); + $this->assertDatabaseHasEntityData('book', $expectedDetails); } public function test_book_name_needed_to_create() @@ -153,23 +152,23 @@ public function test_read_endpoint_includes_chapter_and_page_contents() $directChildCount = $book->directPages()->count() + $book->chapters()->count(); $resp->assertStatus(200); $resp->assertJsonCount($directChildCount, 'contents'); - $resp->assertJson([ - 'contents' => [ - [ - 'type' => 'chapter', - 'id' => $chapter->id, - 'name' => $chapter->name, - 'slug' => $chapter->slug, - 'pages' => [ - [ - 'id' => $chapterPage->id, - 'name' => $chapterPage->name, - 'slug' => $chapterPage->slug, - ] - ] - ] - ] - ]); + + $contents = $resp->json('contents'); + $respChapter = array_values(array_filter($contents, fn ($item) => ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0]; + $this->assertArrayMapIncludes([ + 'id' => $chapter->id, + 'type' => 'chapter', + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], $respChapter); + + $respPage = array_values(array_filter($respChapter['pages'], fn ($item) => ($item['id'] === $chapterPage->id)))[0]; + + $this->assertArrayMapIncludes([ + 'id' => $chapterPage->id, + 'name' => $chapterPage->name, + 'slug' => $chapterPage->slug, + ], $respPage); } public function test_read_endpoint_contents_nested_pages_has_permissions_applied() @@ -189,6 +188,37 @@ public function test_read_endpoint_contents_nested_pages_has_permissions_applied $resp->assertJsonMissing(['name' => $customName]); } + public function test_read_endpoint_lists_visible_shelves_the_book_is_assigned_to() + { + $this->actingAsApiEditor(); + $shelf = $this->entities->shelf(); + $otherShelf = $this->entities->shelf(); + $book = $this->entities->book(); + $book->shelves()->detach(); + + $book->shelves()->attach($shelf); + $book->shelves()->attach($otherShelf); + + $this->assertEquals(2, $book->shelves()->count()); + + $this->permissions->disableEntityInheritedPermissions($otherShelf); + + $resp = $this->getJson("{$this->baseEndpoint}/{$book->id}"); + $resp->assertOk(); + $resp->assertJsonCount(1, 'shelves'); + $resp->assertJson([ + 'shelves' => [ + [ + 'id' => $shelf->id, + 'name' => $shelf->name, + 'slug' => $shelf->slug, + ] + ] + ]); + $resp->assertJsonMissingPath('shelves.0.description'); + $resp->assertJsonMissingPath('shelves.0.pivot'); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); @@ -224,14 +254,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); + $this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $book = $this->entities->book(); - DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); + Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -247,7 +277,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $book */ $book = $this->entities->book(); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -257,7 +287,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -266,7 +296,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -275,7 +305,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 5d7b0530891..953b3a0f5b4 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -91,7 +91,7 @@ public function test_create_endpoint_with_html() 'description' => 'A chapter created via the API', ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('chapters', $expectedDetails); + $this->assertDatabaseHasEntityData('chapter', $expectedDetails); } public function test_chapter_name_needed_to_create() @@ -155,7 +155,7 @@ public function test_read_endpoint() 'owned_by' => $page->owned_by, 'created_by' => $page->created_by, 'updated_by' => $page->updated_by, - 'book_id' => $page->id, + 'book_id' => $page->book->id, 'chapter_id' => $chapter->id, 'priority' => $page->priority, 'book_slug' => $chapter->book->slug, @@ -213,7 +213,7 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('chapters', array_merge($details, [ + $this->assertDatabaseHasEntityData('chapter', array_merge($details, [ 'id' => $chapter->id, 'description' => 'A chapter updated via the API' ])); } @@ -222,7 +222,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $chapter = $this->entities->chapter(); - DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -244,15 +244,15 @@ public function test_update_with_book_id_moves_chapter() $resp->assertOk(); $chapter->refresh(); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); } public function test_update_with_new_book_id_requires_delete_permission() { $editor = $this->users->editor(); $this->permissions->removeUserRolePermissions($editor, ['chapter-delete-all', 'chapter-delete-own']); - $this->actingAs($editor); + $this->actingAsForApi($editor); $chapter = $this->entities->chapterHasPages(); $newBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php index a62abacc75e..464d62683ad 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -280,7 +280,7 @@ public function test_update_can_both_provide_owner_and_fallback_permissions() ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]); $this->assertDatabaseHas('entity_permissions', [ 'entity_id' => $page->id, 'entity_type' => 'page', diff --git a/tests/Api/ExportsApiTest.php b/tests/Api/ExportsApiTest.php index e1ac698d0a4..7951e04d272 100644 --- a/tests/Api/ExportsApiTest.php +++ b/tests/Api/ExportsApiTest.php @@ -19,7 +19,7 @@ public function test_book_html_endpoint() $resp = $this->get("/api/books/{$book->id}/export/html"); $resp->assertStatus(200); $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.html'); } public function test_book_plain_text_endpoint() @@ -30,7 +30,7 @@ public function test_book_plain_text_endpoint() $resp = $this->get("/api/books/{$book->id}/export/plaintext"); $resp->assertStatus(200); $resp->assertSee($book->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.txt'); } public function test_book_pdf_endpoint() @@ -40,7 +40,7 @@ public function test_book_pdf_endpoint() $resp = $this->get("/api/books/{$book->id}/export/pdf"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.pdf'); } public function test_book_markdown_endpoint() @@ -50,7 +50,7 @@ public function test_book_markdown_endpoint() $resp = $this->get("/api/books/{$book->id}/export/markdown"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.md"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.md'); $resp->assertSee('# ' . $book->name); $resp->assertSee('# ' . $book->pages()->first()->name); $resp->assertSee('# ' . $book->chapters()->first()->name); @@ -63,7 +63,7 @@ public function test_book_zip_endpoint() $resp = $this->get("/api/books/{$book->id}/export/zip"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.zip"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $book->slug . '.zip'); $zip = ZipTestHelper::extractFromZipResponse($resp); $this->assertArrayHasKey('book', $zip->data); @@ -77,7 +77,7 @@ public function test_chapter_html_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/html"); $resp->assertStatus(200); $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.html'); } public function test_chapter_plain_text_endpoint() @@ -88,7 +88,7 @@ public function test_chapter_plain_text_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/plaintext"); $resp->assertStatus(200); $resp->assertSee($chapter->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.txt'); } public function test_chapter_pdf_endpoint() @@ -98,7 +98,7 @@ public function test_chapter_pdf_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/pdf"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.pdf'); } public function test_chapter_markdown_endpoint() @@ -108,7 +108,7 @@ public function test_chapter_markdown_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/markdown"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.md"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.md'); $resp->assertSee('# ' . $chapter->name); $resp->assertSee('# ' . $chapter->pages()->first()->name); } @@ -120,7 +120,7 @@ public function test_chapter_zip_endpoint() $resp = $this->get("/api/chapters/{$chapter->id}/export/zip"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.zip"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $chapter->slug . '.zip'); $zip = ZipTestHelper::extractFromZipResponse($resp); $this->assertArrayHasKey('chapter', $zip->data); @@ -134,7 +134,7 @@ public function test_page_html_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/html"); $resp->assertStatus(200); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.html'); } public function test_page_plain_text_endpoint() @@ -145,7 +145,7 @@ public function test_page_plain_text_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/plaintext"); $resp->assertStatus(200); $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.txt'); } public function test_page_pdf_endpoint() @@ -155,7 +155,7 @@ public function test_page_pdf_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/pdf"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.pdf'); } public function test_page_markdown_endpoint() @@ -166,7 +166,7 @@ public function test_page_markdown_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/markdown"); $resp->assertStatus(200); $resp->assertSee('# ' . $page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.md'); } public function test_page_zip_endpoint() @@ -176,7 +176,7 @@ public function test_page_zip_endpoint() $resp = $this->get("/api/pages/{$page->id}/export/zip"); $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.zip"'); + $resp->assertHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . $page->slug . '.zip'); $zip = ZipTestHelper::extractFromZipResponse($resp); $this->assertArrayHasKey('page', $zip->data); diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 6670931074e..07c20c83416 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -275,6 +275,69 @@ public function test_read_endpoint_does_not_show_if_no_permissions_for_related_p $resp->assertStatus(404); } + public function test_read_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_data_endpoint_permission_controlled() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $this->get("{$this->baseEndpoint}/{$image->id}/data")->assertOk(); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(404); + } + + public function test_read_url_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_url_data_endpoint_permission_controlled_when_local_secure_restricted_storage_is_used() + { + config()->set('filesystems.images', 'local_secure_restricted'); + + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(404); + } + public function test_update_endpoint() { $this->actingAsApiAdmin(); diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index ced8954eb11..d71b6c9881d 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use Carbon\Carbon; @@ -199,6 +200,31 @@ public function test_read_endpoint_returns_not_found() $this->assertSame(404, $resp->json('error')['code']); } + public function test_read_endpoint_includes_page_comments_tree_structure() + { + $this->actingAsApiEditor(); + $page = $this->entities->page(); + $relation = ['commentable_type' => 'page', 'commentable_id' => $page->id]; + $active = Comment::factory()->create([...$relation, 'html' => '

My active comment

']); + Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]); + $archived = Comment::factory()->create([...$relation, 'archived' => true]); + Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]); + + $resp = $this->getJson("{$this->baseEndpoint}/{$page->id}"); + $resp->assertOk(); + + $resp->assertJsonCount(1, 'comments.active'); + $resp->assertJsonCount(1, 'comments.archived'); + $resp->assertJsonCount(5, 'comments.active.0.children'); + $resp->assertJsonCount(2, 'comments.archived.0.children'); + + $resp->assertJsonFragment([ + 'id' => $active->id, + 'local_id' => $active->local_id, + 'html' => '

My active comment

', + ]); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); @@ -286,7 +312,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $page = $this->entities->page(); - DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index d174838c27d..9e645fe215b 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -23,7 +23,7 @@ public function test_settings_manage_permission_needed_for_all_endpoints() { $editor = $this->users->editor(); $this->permissions->grantUserRolePermissions($editor, ['settings-manage']); - $this->actingAs($editor); + $this->actingAsForApi($editor); foreach ($this->endpointMap as [$method, $uri]) { $resp = $this->json($method, $uri); @@ -36,7 +36,7 @@ public function test_restrictions_manage_all_permission_needed_for_all_endpoints { $editor = $this->users->editor(); $this->permissions->grantUserRolePermissions($editor, ['restrictions-manage-all']); - $this->actingAs($editor); + $this->actingAsForApi($editor); foreach ($this->endpointMap as [$method, $uri]) { $resp = $this->json($method, $uri); @@ -53,6 +53,7 @@ public function test_index_endpoint_returns_expected_page() $book = $this->entities->book(); $this->actingAs($admin)->delete($page->getUrl()); $this->delete($book->getUrl()); + $this->actingAsForApi($admin); $deletions = Deletion::query()->orderBy('id')->get(); @@ -89,7 +90,7 @@ public function test_index_endpoint_returns_children_count() $deletion = Deletion::query()->orderBy('id')->first(); - $resp = $this->getJson($this->baseEndpoint); + $resp = $this->actingAsForApi($admin)->getJson($this->baseEndpoint); $expectedData = [ [ @@ -115,6 +116,7 @@ public function test_index_endpoint_returns_parent() $this->actingAs($admin)->delete($page->getUrl()); $deletion = Deletion::query()->orderBy('id')->first(); + $this->actingAsForApi($admin); $resp = $this->getJson($this->baseEndpoint); $expectedData = [ @@ -141,10 +143,11 @@ public function test_restore_endpoint() $page = $this->entities->page(); $this->asAdmin()->delete($page->getUrl()); $page->refresh(); + $this->actingAsApiAdmin(); $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -154,7 +157,7 @@ public function test_restore_endpoint() 'restore_count' => 1, ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => null, ]); @@ -165,10 +168,11 @@ public function test_destroy_endpoint() $page = $this->entities->page(); $this->asAdmin()->delete($page->getUrl()); $page->refresh(); + $this->actingAsApiAdmin(); $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -178,6 +182,6 @@ public function test_destroy_endpoint() 'delete_count' => 1, ]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } } diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 9da7900ca9a..517c5d8e4ef 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -113,6 +113,7 @@ public function test_all_endpoint_includes_parent_details_where_visible() $this->permissions->disableEntityInheritedPermissions($book); $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertOk(); $resp->assertJsonPath('data.0.id', $page->id); $resp->assertJsonMissingPath('data.0.book.name'); } diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index ba13c0153b1..34ce0e4e5b2 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -48,8 +48,8 @@ public function test_index_endpoint_includes_cover_if_set() [ 'id' => $shelf->id, 'cover' => [ - 'id' => $shelf->cover->id, - 'url' => $shelf->cover->url, + 'id' => $shelf->coverInfo()->getImage()->id, + 'url' => $shelf->coverInfo()->getImage()->url, ], ], ]]); @@ -102,7 +102,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('bookshelves', $expectedDetails); + $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails); } public function test_shelf_name_needed_to_create() @@ -181,14 +181,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $shelf = Bookshelf::visible()->first(); - DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -222,7 +222,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $shelf */ $shelf = Bookshelf::visible()->first(); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -232,7 +232,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -241,7 +241,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -250,7 +250,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Api/UsersApiTest.php b/tests/Api/UsersApiTest.php index a0c67d0d281..e7b9df6aae9 100644 --- a/tests/Api/UsersApiTest.php +++ b/tests/Api/UsersApiTest.php @@ -80,7 +80,7 @@ public function test_index_endpoint_has_correct_created_and_last_activity_dates( /** @var ActivityModel $activity */ $activity = ActivityModel::query()->where('user_id', '=', $user->id)->latest()->first(); - $resp = $this->asAdmin()->getJson($this->baseEndpoint . '?filter[id]=3'); + $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?filter[id]=3'); $resp->assertJson(['data' => [ [ 'id' => $user->id, diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index 1f359b41a10..5184bf9843c 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Hash; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; @@ -166,6 +167,36 @@ public function test_remove_mfa_method() $this->assertEquals(0, $admin->mfaValues()->count()); } + public function test_mfa_required_if_set_on_role() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + /** @var Role $role */ + $role = $user->roles()->first(); + $role->mfa_enforced = true; + $role->save(); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + + public function test_mfa_required_if_mfa_option_configured() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + $user->mfaValues()->create([ + 'method' => MfaValue::METHOD_TOTP, + 'value' => 'test', + ]); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login() { $admin = $this->users->admin(); diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php index 76c59bc748b..967be1c6a61 100644 --- a/tests/Auth/MfaVerificationTest.php +++ b/tests/Auth/MfaVerificationTest.php @@ -66,6 +66,27 @@ public function test_totp_form_has_autofill_configured() $html->assertElementExists('input[autocomplete="one-time-code"][name="code"]'); } + public function test_totp_verification_is_rate_limited() + { + [$user, $secret, $loginResp] = $this->startTotpLogin(); + $loginService = $this->app->make(LoginService::class); + + $resp = $this->get('/mfa/verify'); + for ($i = 0; $i < 5; $i++) { + $this->post('/mfa/totp/verify', [ + 'code' => '123456', + ])->assertRedirect('/mfa/verify'); + $this->assertNotNull($loginService->getLastLoginAttemptUser()); + } + + $resp = $this->post('/mfa/totp/verify', [ + 'code' => '123456', + ]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.'); + $this->assertNull($loginService->getLastLoginAttemptUser()); + } + public function test_backup_code_verification() { [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); @@ -147,6 +168,27 @@ public function test_backup_code_verification_shows_warning_when_limited_codes_r $resp->assertSeeText('You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.'); } + public function test_backup_code_verification_is_rate_limited() + { + [$user, $codes, $loginResp] = $this->startBackupCodeLogin(['abc12-def45', 'abc12-def46']); + $loginService = $this->app->make(LoginService::class); + + $resp = $this->get('/mfa/verify'); + for ($i = 0; $i < 5; $i++) { + $this->post('/mfa/backup_codes/verify', [ + 'code' => '123456abcd', + ])->assertRedirect('/mfa/verify'); + $this->assertNotNull($loginService->getLastLoginAttemptUser()); + } + + $resp = $this->post('/mfa/backup_codes/verify', [ + 'code' => '123456abcd', + ]); + $resp->assertRedirect('/login'); + $this->assertSessionError('Too many multi-factor verification attempts. Please try again in 60 seconds.'); + $this->assertNull($loginService->getLastLoginAttemptUser()); + } + public function test_backup_code_form_has_autofill_configured() { [$user, $codes, $loginResp] = $this->startBackupCodeLogin(); diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index a0db1c2ba00..8508568f1f4 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -138,7 +138,7 @@ public function test_login_success_flow() { // Start auth $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([ 'email' => 'benny@example.com', @@ -190,6 +190,35 @@ public function test_callback_fails_if_no_state_present_or_matching() $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); } + public function test_callback_works_even_if_other_request_made_by_session() + { + $this->mockHttpClient([$this->getMockAuthorizationResponse([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + ])]); + + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + + $this->get('/'); + + $resp = $this->get("/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state={$state}"); + $resp->assertRedirect('/'); + } + + public function test_callback_fails_if_state_timestamp_is_too_old() + { + $this->post('/oidc/login'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; + session()->put('oidc_state', (time() - 60 * 4) . ':' . $state); + + $this->get('/'); + + $resp = $this->get("/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state={$state}"); + $resp->assertRedirect('/login'); + $this->assertSessionError('Login using SingleSignOn-Testing failed, system did not provide successful authorization'); + } + public function test_dump_user_details_option_outputs_as_expected() { config()->set('oidc.dump_user_details', true); @@ -793,11 +822,39 @@ public function test_oidc_id_token_pre_validate_theme_event_with_return() ]); } + public function test_oidc_auth_pre_redirect_theme_event_with_return() + { + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + return 'https://cats.example.com?beans=true'; + }; + Theme::listen(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $callback); + + $resp = $this->post('/oidc/login'); + $resp->assertRedirect('https://cats.example.com?beans=true'); + + $this->assertCount(1, $args); + $this->assertStringStartsWith('https://oidc.local/auth', $args[0]); + } + + public function test_oidc_auth_pre_redirect_theme_event_with_no_return() + { + $callback = function ($redirectUrl) { + $redirectUrl = 'cat'; + }; + Theme::listen(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $callback); + + $resp = $this->post('/oidc/login'); + $redirect = $resp->headers->get('Location'); + $this->assertStringStartsWith('https://oidc.local/auth?', $redirect); + } + public function test_pkce_used_on_authorize_and_access() { // Start auth $resp = $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $pkceCode = session()->get('oidc_pkce_code'); $this->assertGreaterThan(30, strlen($pkceCode)); @@ -825,7 +882,7 @@ public function test_userinfo_endpoint_used_if_missing_claims_in_id_token() { config()->set('oidc.display_name_claims', 'first_name|last_name'); $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $client = $this->mockHttpClient([ $this->getMockAuthorizationResponse(['name' => null]), @@ -973,7 +1030,7 @@ public function test_userinfo_endpoint_not_called_if_empty_groups_array_provided ]); $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1]; $client = $this->mockHttpClient([$this->getMockAuthorizationResponse([ 'groups' => [], ])]); @@ -999,7 +1056,7 @@ protected function withAutodiscovery(): void protected function runLogin($claimOverrides = [], $additionalHttpResponses = []): TestResponse { $this->post('/oidc/login'); - $state = session()->get('oidc_state'); + $state = explode(':', session()->get('oidc_state'), 2)[1] ?? ''; $this->mockHttpClient([$this->getMockAuthorizationResponse($claimOverrides), ...$additionalHttpResponses]); return $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state); diff --git a/tests/Auth/RegistrationTest.php b/tests/Auth/RegistrationTest.php index 2666fa3b4c7..e0d7c262682 100644 --- a/tests/Auth/RegistrationTest.php +++ b/tests/Auth/RegistrationTest.php @@ -188,6 +188,30 @@ public function test_registration_validation() $resp->assertSee('The password must be at least 8 characters.'); } + public function test_registration_input_filtered_to_validated_input() + { + $this->setSettings(['registration-enabled' => 'true']); + $roleIds = Role::all()->pluck('id')->toArray(); + + $resp = $this->post('/register', [ + 'name' => 'Barry', + 'email' => 'barry@example.com', + 'password' => 'superpassword', + 'password_confirmation' => 'superpassword', + 'external_auth_id' => 'ext5691284', + 'roles' => $roleIds, + ]); + + $resp->assertRedirect('/'); + + /** @var User $user */ + $user = auth()->user(); + $this->assertNotNull($user); + $this->assertFalse($user->isGuest()); + $this->assertEmpty($user->external_auth_id); + $this->assertEquals(0, $user->roles()->count()); + } + public function test_registration_simple_honeypot_active() { $this->setSettings(['registration-enabled' => 'true']); diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 3de6238edc8..6a3063bcf51 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -36,7 +36,7 @@ protected function setUp(): void public function test_metadata_endpoint_displays_xml_as_expected() { $req = $this->get('/saml2/metadata'); - $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + $req->assertHeader('Content-Type', 'text/xml; charset=utf-8'); $req->assertSee('md:EntityDescriptor'); $req->assertSee(url('/saml2/acs')); } @@ -51,7 +51,7 @@ public function test_metadata_endpoint_loads_when_autoloading_with_bad_url_set() $req = $this->get('/saml2/metadata'); $req->assertOk(); - $req->assertHeader('Content-Type', 'text/xml; charset=UTF-8'); + $req->assertHeader('Content-Type', 'text/xml; charset=utf-8'); $req->assertSee('md:EntityDescriptor'); } diff --git a/tests/Commands/CreateAdminCommandTest.php b/tests/Commands/CreateAdminCommandTest.php index 95a39c497e4..f389dd94235 100644 --- a/tests/Commands/CreateAdminCommandTest.php +++ b/tests/Commands/CreateAdminCommandTest.php @@ -2,8 +2,11 @@ namespace Tests\Commands; +use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Tests\TestCase; class CreateAdminCommandTest extends TestCase @@ -11,14 +14,14 @@ class CreateAdminCommandTest extends TestCase public function test_standard_command_usage() { $this->artisan('bookstack:create-admin', [ - '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', '--password' => 'testing-4', ])->assertExitCode(0); $this->assertDatabaseHas('users', [ 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'name' => 'Admin Test', ]); /** @var User $user */ @@ -30,14 +33,14 @@ public function test_standard_command_usage() public function test_providing_external_auth_id() { $this->artisan('bookstack:create-admin', [ - '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', '--external-auth-id' => 'xX_admin_Xx', ])->assertExitCode(0); $this->assertDatabaseHas('users', [ - 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'email' => 'admintest@example.com', + 'name' => 'Admin Test', 'external_auth_id' => 'xX_admin_Xx', ]); @@ -50,14 +53,178 @@ public function test_password_required_if_external_auth_id_not_given() { $this->artisan('bookstack:create-admin', [ '--email' => 'admintest@example.com', - '--name' => 'Admin Test', + '--name' => 'Admin Test', ])->expectsQuestion('Please specify a password for the new admin user (8 characters min)', 'hunter2000') ->assertExitCode(0); $this->assertDatabaseHas('users', [ 'email' => 'admintest@example.com', - 'name' => 'Admin Test', + 'name' => 'Admin Test', ]); $this->assertTrue(Auth::attempt(['email' => 'admintest@example.com', 'password' => 'hunter2000'])); } + + public function test_generate_password_option() + { + $this->withoutMockingConsoleOutput() + ->artisan('bookstack:create-admin', [ + '--email' => 'admintest@example.com', + '--name' => 'Admin Test', + '--generate-password' => true, + ]); + + $output = trim(Artisan::output()); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output); + + $user = User::query()->where('email', '=', 'admintest@example.com')->first(); + $this->assertTrue(Hash::check($output, $user->password)); + } + + public function test_initial_option_updates_default_admin() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput('The default admin user has been updated with the provided details!') + ->assertExitCode(0); + + $defaultAdmin->refresh(); + + $this->assertEquals('firstadmin@example.com', $defaultAdmin->email); + } + + public function test_initial_option_does_not_update_if_only_non_default_admin_exists() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + $defaultAdmin->email = 'testadmin@example.com'; + $defaultAdmin->save(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput('Non-default admin user already exists. Skipping creation of new admin user.') + ->assertExitCode(2); + + $defaultAdmin->refresh(); + + $this->assertEquals('testadmin@example.com', $defaultAdmin->email); + } + + public function test_initial_option_updates_creates_new_admin_if_none_exists() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $this->assertEquals(0, $adminRole->users()->count()); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!") + ->assertExitCode(0); + + $this->assertEquals(1, $adminRole->users()->count()); + $this->assertDatabaseHas('users', [ + 'email' => 'firstadmin@example.com', + 'name' => 'My initial admin', + ]); + } + + public function test_initial_rerun_does_not_error_but_skips() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Admin account with email \"firstadmin@example.com\" successfully created!") + ->assertExitCode(0); + + $this->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Non-default admin user already exists. Skipping creation of new admin user.") + ->assertExitCode(2); + } + + public function test_initial_option_creation_errors_if_email_already_exists() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $editor = $this->users->editor(); + + $this->artisan('bookstack:create-admin', [ + '--email' => $editor->email, + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Could not create admin account.") + ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.") + ->assertExitCode(1); + } + + public function test_initial_option_updating_errors_if_email_already_exists() + { + $editor = $this->users->editor(); + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + $this->assertNotNull($defaultAdmin); + + $this->artisan('bookstack:create-admin', [ + '--email' => $editor->email, + '--name' => 'My initial admin', + '--password' => 'testing-7', + '--initial' => true, + ])->expectsOutput("Could not create admin account.") + ->expectsOutput("An account with the email address \"{$editor->email}\" already exists.") + ->assertExitCode(1); + } + + public function test_initial_option_does_not_require_name_or_email_to_be_passed() + { + $adminRole = Role::getSystemRole('admin'); + $adminRole->users()->delete(); + $this->assertEquals(0, $adminRole->users()->count()); + + $this->artisan('bookstack:create-admin', [ + '--generate-password' => true, + '--initial' => true, + ])->assertExitCode(0); + + $this->assertEquals(1, $adminRole->users()->count()); + $this->assertDatabaseHas('users', [ + 'email' => 'admin@example.com', + 'name' => 'Admin', + ]); + } + + public function test_initial_option_updating_existing_user_with_generate_password_only_outputs_password() + { + $defaultAdmin = User::query()->where('email', '=', 'admin@admin.com')->first(); + + $this->withoutMockingConsoleOutput() + ->artisan('bookstack:create-admin', [ + '--email' => 'firstadmin@example.com', + '--name' => 'Admin Test', + '--generate-password' => true, + '--initial' => true, + ]); + + $output = Artisan::output(); + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9]{32}$/', $output); + + $defaultAdmin->refresh(); + $this->assertEquals('firstadmin@example.com', $defaultAdmin->email); + } } diff --git a/tests/Commands/InstallModuleCommandTest.php b/tests/Commands/InstallModuleCommandTest.php new file mode 100644 index 00000000000..8ffc4ead3a0 --- /dev/null +++ b/tests/Commands/InstallModuleCommandTest.php @@ -0,0 +1,299 @@ +usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + $expectedInstallPath = theme_path('modules/test-module'); + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsOutput("\nThis will install a module from: {$zip}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $this->assertDirectoryExists($expectedInstallPath); + $this->assertFileExists($expectedInstallPath . '/bookstack-module.json'); + }); + } + + public function test_remote_module_install_with_active_theme() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + $expectedInstallPath = theme_path('modules/test-module'); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsOutput("\nThis will download a module from: example.com\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $this->assertEquals(1, $http->requestCount()); + $request = $http->requestAt(0); + $this->assertEquals('/test-module.zip', $request->getUri()->getPath()); + + $this->assertDirectoryExists($expectedInstallPath); + $this->assertFileExists($expectedInstallPath . '/bookstack-module.json'); + }); + } + + public function test_remote_http_module_warns_and_prompts_users() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + $expectedInstallPath = theme_path('modules/test-module'); + + $this->artisan('bookstack:install-module', ['location' => 'http://example.com/test-module.zip']) + ->expectsOutput("\nThis will download a module from: example.com\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.") + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.") + ->expectsConfirmation('Are you sure you want to continue without HTTPS?', 'yes') + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->expectsOutput("Install location: {$expectedInstallPath}") + ->assertExitCode(0); + + $request = $http->requestAt(0); + $this->assertEquals('/test-module.zip', $request->getUri()->getPath()); + }); + } + + public function test_remote_module_install_follows_redirects() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'https://example.com/a-test-module.zip']), + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->assertExitCode(0); + + $this->assertEquals(2, $http->requestCount()); + $this->assertEquals('/test-module.zip', $http->requestAt(0)->getUri()->getPath()); + $this->assertEquals('/a-test-module.zip', $http->requestAt(1)->getUri()->getPath()); + }); + } + + public function test_remote_module_install_does_not_follow_redirects_to_different_origin() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + + $http = $this->mockHttpClient([ + new Response(302, ['Location' => 'http://example.com/a-test-module.zip']), + new Response(200, ['Content-Length' => filesize($zip)], file_get_contents($zip)) + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->assertExitCode(1); + + $this->assertEquals(1, $http->requestCount()); + $this->assertEquals('https', $http->requestAt(0)->getUri()->getScheme()); + }); + } + + public function test_remote_module_install_download_failures_are_announced_to_user() + { + $this->usingThemeFolder(function () { + $http = $this->mockHttpClient([ + new Response(404), + ]); + + $this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip']) + ->expectsConfirmation('Are you sure you trust this source?', 'yes') + ->expectsOutput('ERROR: Failed to download module from https://example.com/test-module.zip') + ->expectsOutput('Download failed with status code 404') + ->assertExitCode(1); + $this->assertEquals(1, $http->requestCount()); + }); + } + + public function test_run_with_invalid_path_exits_early() + { + $this->artisan('bookstack:install-module', ['location' => '/not-found.zip']) + ->expectsOutput('ERROR: Module file not found at /not-found.zip') + ->assertExitCode(1); + } + + public function test_run_with_invalid_zip_has_early_exit() + { + $zip = $this->getModuleZipPath(); + file_put_contents($zip, 'invalid zip'); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Cannot open ZIP file at {$zip}") + ->assertExitCode(1); + } + + public function test_run_with_large_zip_has_early_exit() + { + $zip = $this->getModuleZipPath(null, [ + 'large-file.txt' => str_repeat('a', 1024 * 1024 * 51) + ]); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Module ZIP file contents are too large. Maximum size is 50MB") + ->assertExitCode(1); + } + + public function test_run_with_invalid_module_data_has_early_exit() + { + $zip = $this->getModuleZipPath([ + 'name' => 'Invalid Module', + 'description' => 'A module with invalid data', + 'version' => 'dog', + ]); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Failed to read module metadata with error: Module in folder \"_temp\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'") + ->assertExitCode(1); + } + + public function test_local_module_install_without_active_theme_can_setup_theme_folder() + { + $zip = $this->getModuleZipPath(); + $expectedThemePath = base_path('themes/custom'); + File::deleteDirectory($expectedThemePath); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsConfirmation('No active theme folder found, would you like to create one?', 'yes') + ->expectsOutput("Created theme folder at {$expectedThemePath}") + ->expectsOutput("You will need to set APP_THEME=custom in your BookStack env configuration to enable this theme!") + ->expectsOutput('Module "Test Module" (v1.0.0) successfully installed!') + ->assertExitCode(0); + + $this->assertDirectoryExists($expectedThemePath . '/modules/test-module'); + + File::deleteDirectory($expectedThemePath); + } + + public function test_local_module_install_with_active_theme_and_conflicting_modules_file_causes_early_exit() + { + $this->usingThemeFolder(function () { + $zip = $this->getModuleZipPath(); + File::put(theme_path('modules'), '{}'); + + $this->artisan('bookstack:install-module', ['location' => $zip]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput("ERROR: Cannot create a modules folder, file already exists at " . theme_path('modules')) + ->assertExitCode(1); + }); + } + + public function test_single_existing_module_with_same_name_replace() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Replace existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->expectsOutput("Replacing existing module in test-module folder") + ->assertExitCode(0); + + $this->assertFileExists($original . '/bookstack-module.json'); + $metadata = json_decode(file_get_contents($original . '/bookstack-module.json'), true); + $this->assertEquals('2.0.0', $metadata['version']); + }); + } + + public function test_single_existing_module_with_same_name_cancel() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Cancel module install', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->assertExitCode(1); + + $this->assertFileExists($original . '/bookstack-module.json'); + $metadata = json_decode(file_get_contents($original . '/bookstack-module.json'), true); + $this->assertEquals('1.0.0', $metadata['version']); + }); + } + + public function test_single_existing_module_with_same_name_add() + { + $this->usingThemeFolder(function () { + $original = $this->createModuleFolderInCurrentTheme(['name' => 'Test Module', 'description' => 'cat', 'version' => '1.0.0']); + $new = $this->getModuleZipPath(['name' => 'Test Module', 'description' => '', 'version' => '2.0.0']); + + $this->artisan('bookstack:install-module', ['location' => $new]) + ->expectsConfirmation('Are you sure you want to install this module?', 'yes') + ->expectsOutput('The following modules already exist with the same name:') + ->expectsOutput('Test Module (test-module:v1.0.0) - cat') + ->expectsChoice('What would you like to do?', 'Add alongside existing module', ['Cancel module install', 'Add alongside existing module', 'Replace existing module']) + ->assertExitCode(0); + + $dirs = File::directories(theme_path('modules/')); + $this->assertCount(2, $dirs); + }); + } + + protected function createModuleFolderInCurrentTheme(array|null $metadata = null, array $extraFiles = []): string + { + $original = $this->getModuleZipPath($metadata, $extraFiles); + $targetPath = theme_path('modules/test-module'); + mkdir($targetPath, 0777, true); + $originalZip = new ZipArchive(); + $originalZip->open($original); + $originalZip->extractTo($targetPath); + $originalZip->close(); + + return $targetPath; + } + + protected function getModuleZipPath(array|null $metadata = null, array $extraFiles = []): string + { + $zip = new ZipArchive(); + $tmpFile = tempnam(sys_get_temp_dir(), 'bs-test-module'); + $zip->open($tmpFile, ZipArchive::CREATE); + + $zip->addFromString('bookstack-module.json', json_encode($metadata ?? [ + 'name' => 'Test Module', + 'description' => 'A test module for BookStack', + 'version' => '1.0.0', + ])); + + foreach ($extraFiles as $path => $contents) { + $zip->addFromString($path, $contents); + } + + $zip->close(); + return $tmpFile; + } +} diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index d336e05a240..356a026a849 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -19,7 +19,7 @@ public function test_command_updates_page_content() ->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', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'html' => '', ]); @@ -40,7 +40,7 @@ public function test_command_updates_description_html() ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); foreach ($models as $model) { - $this->assertDatabaseHas($model->getTable(), [ + $this->assertDatabaseHasEntityData($model->getMorphClass(), [ 'id' => $model->id, 'description_html' => '', ]); diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index ad1d64e7126..3ba2c3e99c8 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -91,7 +91,7 @@ public function test_shelves_create() ])); $resp->assertRedirect(); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); $shelfPage = $this->get($shelf->getUrl()); @@ -117,11 +117,12 @@ public function test_shelves_create_sets_cover_image() $lastImage = Image::query()->orderByDesc('id')->firstOrFail(); $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first(); - $this->assertDatabaseHas('bookshelves', [ - 'id' => $shelf->id, + $this->assertDatabaseHas('entity_container_data', [ + 'entity_id' => $shelf->id, + 'entity_type' => 'bookshelf', 'image_id' => $lastImage->id, ]); - $this->assertEquals($lastImage->id, $shelf->cover->id); + $this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id); $this->assertEquals('cover_bookshelf', $lastImage->type); } @@ -247,7 +248,7 @@ public function test_shelf_edit() $this->assertSessionHas('success'); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); $shelfPage = $this->get($shelf->getUrl()); $shelfPage->assertSee($shelfInfo['name']); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 51bf65d10bb..6082c59de61 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -27,7 +27,7 @@ public function test_create() $resp = $this->get('/books/my-first-book'); $resp->assertSee($book->name); - $resp->assertSee($book->description); + $resp->assertSee($book->descriptionInfo()->getPlain()); } public function test_create_uses_different_slugs_when_name_reused() @@ -154,6 +154,20 @@ public function test_delete() $this->assertNotificationContains($redirectReq, 'Book Successfully Deleted'); } + public function test_delete_with_shelf_context_returns_to_shelf_view_after_delete() + { + $shelf = $this->entities->shelfHasBooks(); + /** @var Book $book */ + $book = $shelf->books()->first(); + + $this->asEditor()->get($shelf->getUrl()); + $this->get($book->getUrl()); + $this->get($book->getUrl('/delete')); + $resp = $this->delete($book->getUrl()); + + $resp->assertRedirect($shelf->getUrl()); + } + public function test_cancel_on_create_page_leads_back_to_books_listing() { $resp = $this->asEditor()->get('/create-book'); @@ -238,30 +252,6 @@ public function test_books_view_shows_view_toggle_option() $this->assertEquals('list', setting()->getUser($editor, 'books_view_type')); } - public function test_slug_multi_byte_url_safe() - { - $book = $this->entities->newBook([ - 'name' => 'информация', - ]); - - $this->assertEquals('informaciia', $book->slug); - - $book = $this->entities->newBook([ - 'name' => '¿Qué?', - ]); - - $this->assertEquals('que', $book->slug); - } - - public function test_slug_format() - { - $book = $this->entities->newBook([ - 'name' => 'PartA / PartB / PartC', - ]); - - $this->assertEquals('parta-partb-partc', $book->slug); - } - public function test_description_limited_to_specific_html() { $book = $this->entities->book(); @@ -289,107 +279,24 @@ public function test_show_view_displays_description_if_no_description_html_set() $resp->assertSee("

My great
\ndescription
\n
\nwith newlines

", false); } - public function test_show_view_has_copy_button() + public function test_description_with_only_br_tags_results_in_empty_p_tag_used_on_show() { + $descriptions = [ + '


', + '





', + '









', + ]; $book = $this->entities->book(); - $resp = $this->asEditor()->get($book->getUrl()); - - $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); - } - - public function test_copy_view() - { - $book = $this->entities->book(); - $resp = $this->asEditor()->get($book->getUrl('/copy')); - - $resp->assertOk(); - $resp->assertSee('Copy Book'); - $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); - } - - public function test_copy() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); - $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + $this->asEditor(); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); + foreach ($descriptions as $descriptionTestCase) { + $book->description_html = $descriptionTestCase; + $book->save(); - $resp->assertRedirect($copy->getUrl()); - $this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count()); - - $this->get($copy->getUrl())->assertSee($book->description_html, false); - } - - public function test_copy_does_not_copy_non_visible_content() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); - - // Hide child content - /** @var BookChild $page */ - foreach ($book->getDirectVisibleChildren() as $child) { - $this->permissions->setEntityPermissions($child, [], []); + $resp = $this->get($book->getUrl()); + $html = $this->withHtml($resp); + $descriptionHtml = $html->getInnerHtml('.book-content > div.text-muted:first-child'); + $this->assertEquals('

', $descriptionHtml); } - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertEquals(0, $copy->getDirectVisibleChildren()->count()); - } - - public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create() - { - /** @var Book $book */ - $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']); - - $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertEquals(0, $copy->pages()->count()); - $this->assertEquals(0, $copy->chapters()->count()); - } - - public function test_copy_clones_cover_image_if_existing() - { - $book = $this->entities->book(); - $bookRepo = $this->app->make(BookRepo::class); - $coverImageFile = $this->files->uploadedImage('cover.png'); - $bookRepo->updateCoverImage($book, $coverImageFile); - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertNotNull($copy->cover); - $this->assertNotEquals($book->cover->id, $copy->cover->id); - } - - public function test_copy_adds_book_to_shelves_if_edit_permissions_allows() - { - /** @var Bookshelf $shelfA */ - /** @var Bookshelf $shelfB */ - [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get(); - $book = $this->entities->book(); - - $shelfA->appendBook($book); - $shelfB->appendBook($book); - - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); - $this->permissions->setEntityPermissions($shelfB); - - - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); - /** @var Book $copy */ - $copy = Book::query()->where('name', '=', 'My copy book')->first(); - - $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists()); - $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists()); } } diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php index 1577cee76d8..0c0ec784135 100644 --- a/tests/Entity/ChapterTest.php +++ b/tests/Entity/ChapterTest.php @@ -66,90 +66,7 @@ public function test_delete() $this->assertNotificationContains($redirectReq, 'Chapter Successfully Deleted'); } - public function test_show_view_has_copy_button() - { - $chapter = $this->entities->chapter(); - - $resp = $this->asEditor()->get($chapter->getUrl()); - $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); - } - - public function test_copy_view() - { - $chapter = $this->entities->chapter(); - - $resp = $this->asEditor()->get($chapter->getUrl('/copy')); - $resp->assertOk(); - $resp->assertSee('Copy Chapter'); - $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); - $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]'); - } - - public function test_copy() - { - /** @var Chapter $chapter */ - $chapter = Chapter::query()->whereHas('pages')->first(); - /** @var Book $otherBook */ - $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); - - $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - 'entity_selection' => 'book:' . $otherBook->id, - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - - $resp->assertRedirect($newChapter->getUrl()); - $this->assertEquals($otherBook->id, $newChapter->book_id); - $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); - } - - public function test_copy_does_not_copy_non_visible_pages() - { - $chapter = $this->entities->chapterHasPages(); - // Hide pages to all non-admin roles - /** @var Page $page */ - foreach ($chapter->pages as $page) { - $this->permissions->setEntityPermissions($page, [], []); - } - - $this->asEditor()->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - $this->assertEquals(0, $newChapter->pages()->count()); - } - - public function test_copy_does_not_copy_pages_if_user_cant_page_create() - { - $chapter = $this->entities->chapterHasPages(); - $viewer = $this->users->viewer(); - $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']); - - // Lacking permission results in no copied pages - $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied chapter', - ]); - - /** @var Chapter $newChapter */ - $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); - $this->assertEquals(0, $newChapter->pages()->count()); - - $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']); - - // Having permission rules in copied pages - $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ - 'name' => 'My copied again chapter', - ]); - - /** @var Chapter $newChapter2 */ - $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); - $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); - } public function test_sort_book_action_visible_if_permissions_allow() { diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index d9b1ee466cf..8658e76998b 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -35,8 +35,8 @@ public function test_convert_chapter_to_book() /** @var Book $newBook */ $newBook = Book::query()->orderBy('id', 'desc')->first(); - $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); + $this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); $this->assertCount(1, $newBook->tags); $this->assertEquals('Category', $newBook->tags->first()->name); $this->assertEquals('Penguins', $newBook->tags->first()->value); @@ -100,7 +100,7 @@ public function test_book_convert_to_shelf() // Checks for new shelf $resp->assertRedirectContains('/shelves/'); - $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]); + $this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']); $this->assertCount(1, $newShelf->tags); $this->assertEquals('Category', $newShelf->tags->first()->name); $this->assertEquals('Ducks', $newShelf->tags->first()->value); @@ -112,8 +112,8 @@ public function test_book_convert_to_shelf() $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf); // Checks for old book to contain child pages - $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]); // Checks for nested page $chapterChildPage->refresh(); diff --git a/tests/Entity/CopyTest.php b/tests/Entity/CopyTest.php new file mode 100644 index 00000000000..d4b6d54cf88 --- /dev/null +++ b/tests/Entity/CopyTest.php @@ -0,0 +1,399 @@ +entities->book(); + $resp = $this->asEditor()->get($book->getUrl()); + + $this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_book_copy_view() + { + $book = $this->entities->book(); + $resp = $this->asEditor()->get($book->getUrl('/copy')); + + $resp->assertOk(); + $resp->assertSee('Copy Book'); + $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); + } + + public function test_book_copy() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $resp->assertRedirect($copy->getUrl()); + $this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count()); + + $this->get($copy->getUrl())->assertSee($book->description_html, false); + } + + public function test_book_copy_does_not_copy_non_visible_content() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + + // Hide child content + /** @var BookChild $page */ + foreach ($book->getDirectVisibleChildren() as $child) { + $this->permissions->setEntityPermissions($child, [], []); + } + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->getDirectVisibleChildren()->count()); + } + + public function test_book_copy_does_not_copy_pages_or_chapters_if_user_cant_create() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-create-all']); + + $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->pages()->count()); + $this->assertEquals(0, $copy->chapters()->count()); + } + + public function test_book_copy_clones_cover_image_if_existing() + { + $book = $this->entities->book(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->files->uploadedImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect(); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertNotNull($copy->coverInfo()->getImage()); + $this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id); + } + + public function test_book_copy_adds_book_to_shelves_if_edit_permissions_allows() + { + /** @var Bookshelf $shelfA */ + /** @var Bookshelf $shelfB */ + [$shelfA, $shelfB] = Bookshelf::query()->take(2)->get(); + $book = $this->entities->book(); + + $shelfA->appendBook($book); + $shelfB->appendBook($book); + + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']); + $this->permissions->setEntityPermissions($shelfB); + + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists()); + $this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists()); + } + + public function test_chapter_show_view_has_copy_button() + { + $chapter = $this->entities->chapter(); + + $resp = $this->asEditor()->get($chapter->getUrl()); + $this->withHtml($resp)->assertElementContains("a[href$=\"{$chapter->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_chapter_copy_view() + { + $chapter = $this->entities->chapter(); + + $resp = $this->asEditor()->get($chapter->getUrl('/copy')); + $resp->assertOk(); + $resp->assertSee('Copy Chapter'); + $this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$chapter->name}\"]"); + $this->withHtml($resp)->assertElementExists('input[name="entity_selection"]'); + } + + public function test_chapter_copy() + { + /** @var Chapter $chapter */ + $chapter = Chapter::query()->whereHas('pages')->first(); + /** @var Book $otherBook */ + $otherBook = Book::query()->where('id', '!=', $chapter->book_id)->first(); + + $resp = $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + 'entity_selection' => 'book:' . $otherBook->id, + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $resp->assertRedirect($newChapter->getUrl()); + $this->assertEquals($otherBook->id, $newChapter->book_id); + $this->assertEquals($chapter->pages->count(), $newChapter->pages->count()); + } + + public function test_chapter_copy_does_not_copy_non_visible_pages() + { + $chapter = $this->entities->chapterHasPages(); + + // Hide pages to all non-admin roles + /** @var Page $page */ + foreach ($chapter->pages as $page) { + $this->permissions->setEntityPermissions($page, [], []); + } + + $this->asEditor()->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + } + + public function test_chapter_copy_does_not_copy_pages_if_user_cant_page_create() + { + $chapter = $this->entities->chapterHasPages(); + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['chapter-create-all']); + + // Lacking permission results in no copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied chapter', + ]); + + /** @var Chapter $newChapter */ + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $this->assertEquals(0, $newChapter->pages()->count()); + + $this->permissions->grantUserRolePermissions($viewer, ['page-create-all']); + + // Having permission rules in copied pages + $this->actingAs($viewer)->post($chapter->getUrl('/copy'), [ + 'name' => 'My copied again chapter', + ]); + + /** @var Chapter $newChapter2 */ + $newChapter2 = Chapter::query()->where('name', '=', 'My copied again chapter')->first(); + $this->assertEquals($chapter->pages()->count(), $newChapter2->pages()->count()); + } + + public function test_book_copy_updates_internal_references() + { + $book = $this->entities->bookHasChaptersAndPages(); + /** @var Chapter $chapter */ + $chapter = $book->chapters()->first(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + $this->asEditor(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

This is a test book link

', + ]); + + // Quick pre-update to get stable slug + $this->put($book->getUrl(), ['name' => 'Internal ref test']); + $book->refresh(); + $page->refresh(); + + $html = '

This is a test page link

'; + $this->put($book->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]); + + $this->post($book->getUrl('/copy'), ['name' => 'My copied book']); + + $newBook = Book::query()->where('name', '=', 'My copied book')->first(); + $newPage = $newBook->pages()->where('name', '=', 'reference test page')->first(); + + $this->assertStringContainsString($newBook->getUrl(), $newPage->html); + $this->assertStringContainsString($newPage->getUrl(), $newBook->description_html); + + $this->assertStringNotContainsString($book->getUrl(), $newPage->html); + $this->assertStringNotContainsString($page->getUrl(), $newBook->description_html); + } + + public function test_chapter_copy_updates_internal_references() + { + $chapter = $this->entities->chapterHasPages(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + $this->asEditor(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

This is a test chapter link

', + ]); + + // Quick pre-update to get stable slug + $this->put($chapter->getUrl(), ['name' => 'Internal ref test']); + $chapter->refresh(); + $page->refresh(); + + $html = '

This is a test page link

'; + $this->put($chapter->getUrl(), ['name' => 'Internal ref test', 'description_html' => $html]); + + $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']); + + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + $newPage = $newChapter->pages()->where('name', '=', 'reference test page')->first(); + + $this->assertStringContainsString($newChapter->getUrl() . '"', $newPage->html); + $this->assertStringContainsString($newPage->getUrl() . '"', $newChapter->description_html); + + $this->assertStringNotContainsString($chapter->getUrl() . '"', $newPage->html); + $this->assertStringNotContainsString($page->getUrl() . '"', $newChapter->description_html); + } + + public function test_chapter_copy_updates_internal_permalink_references_in_its_description() + { + $chapter = $this->entities->chapterHasPages(); + /** @var Page $page */ + $page = $chapter->pages()->first(); + + $this->asEditor()->put($chapter->getUrl(), [ + 'name' => 'Internal ref test', + 'description_html' => '

This is a test page link

', + ]); + $chapter->refresh(); + + $this->post($chapter->getUrl('/copy'), ['name' => 'My copied chapter']); + $newChapter = Chapter::query()->where('name', '=', 'My copied chapter')->first(); + + $this->assertStringContainsString('/link/', $newChapter->description_html); + $this->assertStringNotContainsString($page->getPermalink() . '"', $newChapter->description_html); + } + + public function test_page_copy_updates_internal_self_references() + { + $page = $this->entities->page(); + $this->asEditor(); + + // Initial update to get stable slug + $this->entities->updatePage($page, ['name' => 'reference test page']); + + $page->refresh(); + $this->entities->updatePage($page, [ + 'name' => 'reference test page', + 'html' => '

This is a test page link

', + ]); + + $this->post($page->getUrl('/copy'), ['name' => 'My copied page']); + $newPage = Page::query()->where('name', '=', 'My copied page')->first(); + $this->assertNotNull($newPage); + + $this->assertStringContainsString($newPage->getUrl(), $newPage->html); + $this->assertStringNotContainsString($page->getUrl(), $newPage->html); + } + + public function test_page_copy() + { + $page = $this->entities->page(); + $page->html = '

This is some test content

'; + $page->save(); + + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book'); + $this->assertStringContainsString('This is some test content', $pageCopy->html); + } + + public function test_page_copy_with_markdown_has_both_html_and_markdown() + { + $page = $this->entities->page(); + $page->html = '

This is some test content

'; + $page->markdown = '# This is some test content'; + $page->save(); + $newBook = Book::where('id', '!=', $page->book->id)->first(); + + $this->asEditor()->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $this->assertStringContainsString('This is some test content', $pageCopy->html); + $this->assertEquals('# This is some test content', $pageCopy->markdown); + } + + public function test_page_copy_with_no_destination() + { + $page = $this->entities->page(); + $currentBook = $page->book; + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'name' => 'My copied test page', + ]); + + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book'); + $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance'); + } + + public function test_page_can_be_copied_without_edit_permission() + { + $page = $this->entities->page(); + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + $viewer = $this->users->viewer(); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertDontSee($page->getUrl('/copy')); + + $newBook->owned_by = $viewer->id; + $newBook->save(); + $this->permissions->grantUserRolePermissions($viewer, ['page-create-own']); + $this->permissions->regenerateForEntity($newBook); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertSee($page->getUrl('/copy')); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page', + ]); + $movePageResp->assertRedirect(); + + $this->assertDatabaseHasEntityData('page', [ + 'name' => 'My copied test page', + 'created_by' => $viewer->id, + 'book_id' => $newBook->id, + ]); + } +} diff --git a/tests/Entity/DefaultTemplateTest.php b/tests/Entity/DefaultTemplateTest.php index 5369a5430bc..d3109c8a2fe 100644 --- a/tests/Entity/DefaultTemplateTest.php +++ b/tests/Entity/DefaultTemplateTest.php @@ -18,7 +18,7 @@ public function test_creating_book_with_default_template() ]; $this->asEditor()->post('/books', $details); - $this->assertDatabaseHas('books', $details); + $this->assertDatabaseHasEntityData('book', $details); } public function test_creating_chapter_with_default_template() @@ -31,7 +31,7 @@ public function test_creating_chapter_with_default_template() ]; $this->asEditor()->post($book->getUrl('/create-chapter'), $details); - $this->assertDatabaseHas('chapters', $details); + $this->assertDatabaseHasEntityData('chapter', $details); } public function test_updating_book_with_default_template() @@ -40,10 +40,10 @@ public function test_updating_book_with_default_template() $templatePage = $this->entities->templatePage(); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_updating_chapter_with_default_template() @@ -52,10 +52,10 @@ public function test_updating_chapter_with_default_template() $templatePage = $this->entities->templatePage(); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_default_book_template_cannot_be_set_if_not_a_template() @@ -65,7 +65,7 @@ public function test_default_book_template_cannot_be_set_if_not_a_template() $this->assertFalse($page->template); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_a_template() @@ -75,7 +75,7 @@ public function test_default_chapter_template_cannot_be_set_if_not_a_template() $this->assertFalse($page->template); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } @@ -86,7 +86,7 @@ public function test_default_book_template_cannot_be_set_if_not_have_access() $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_have_access() @@ -96,7 +96,7 @@ public function test_default_chapter_template_cannot_be_set_if_not_have_access() $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_inaccessible_book_default_template_can_be_set_if_unchanged() @@ -106,7 +106,7 @@ public function test_inaccessible_book_default_template_can_be_set_if_unchanged( $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); } public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged() @@ -116,7 +116,7 @@ public function test_inaccessible_chapter_default_template_can_be_set_if_unchang $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); } public function test_default_page_template_option_shows_on_book_form() @@ -173,7 +173,7 @@ public function test_creating_book_page_uses_book_default_template() $templatePage->forceFill(['html' => '

My template page

', 'markdown' => '# My template page'])->save(); $book = $this->bookUsingDefaultTemplate($templatePage); - $this->asEditor()->get($book->getUrl('/create-page')); + $this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect(); $latestPage = $book->pages() ->where('draft', '=', true) ->where('template', '=', false) @@ -251,7 +251,7 @@ public function test_creating_page_as_guest_uses_default_template() $this->post($book->getUrl('/create-guest-page'), [ 'name' => 'My guest page with template' - ]); + ])->assertRedirect(); $latestBookPage = $book->pages() ->where('draft', '=', false) ->where('template', '=', false) diff --git a/tests/Entity/EntityQueryTest.php b/tests/Entity/EntityQueryTest.php new file mode 100644 index 00000000000..180cb3076c6 --- /dev/null +++ b/tests/Entity/EntityQueryTest.php @@ -0,0 +1,44 @@ +assertEquals($expected, $query->toSql()); + $this->assertEquals(['book', 'book'], $query->getBindings()); + } + + public function test_joins_in_sub_queries_use_alias_names() + { + $query = Book::query()->whereHas('chapters', function (Builder $query) { + $query->where('name', '=', 'a'); + }); + + // Probably from type limits on relation where not needed? + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `name` = ? and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertStringMatchesFormat($expected, $query->toSql()); + $this->assertEquals(['book', 'chapter', 'a', 'chapter', 'book'], $query->getBindings()); + } + + public function test_book_chapter_relation_applies_type_condition() + { + $book = $this->entities->book(); + $query = $book->chapters(); + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`book_id` = ? and `entities`.`book_id` is not null and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertEquals($expected, $query->toSql()); + $this->assertEquals(['chapter', $book->id, 'chapter'], $query->getBindings()); + + $query = Book::query()->whereHas('chapters'); + $expected = 'select * from `entities` left join `entity_container_data` on `entity_container_data`.`entity_id` = `entities`.`id` and `entity_container_data`.`entity_type` = ? where exists (select * from `entities` as `laravel_reserved_%d` left join `entity_container_data` on `entity_container_data`.`entity_id` = `laravel_reserved_%d`.`id` and `entity_container_data`.`entity_type` = ? where `entities`.`id` = `laravel_reserved_%d`.`book_id` and `type` = ? and `laravel_reserved_%d`.`deleted_at` is null) and `type` = ? and `entities`.`deleted_at` is null'; + $this->assertStringMatchesFormat($expected, $query->toSql()); + $this->assertEquals(['book', 'chapter', 'chapter', 'book'], $query->getBindings()); + } +} diff --git a/tests/Entity/PageContentFilteringTest.php b/tests/Entity/PageContentFilteringTest.php new file mode 100644 index 00000000000..449189a898c --- /dev/null +++ b/tests/Entity/PageContentFilteringTest.php @@ -0,0 +1,502 @@ +asEditor(); + $page = $this->entities->page(); + $script = 'abc123abc123'; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee($script, false); + $pageView->assertSee('abc123abc123'); + } + + public function test_more_complex_content_script_escaping_scenarios() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + "

Some script

", + "

Some script

", + "

Some script

", + "

Some script

", + "

Some script

", + "

Some script

", + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + } + } + + public function test_js_and_base64_src_urls_are_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + $html->assertElementNotContains('.page-content', 'assertElementNotContains('.page-content', 'data='); + $html->assertElementNotContains('.page-content', ''); + $html->assertElementNotContains('.page-content', 'src='); + $html->assertElementNotContains('.page-content', 'javascript:'); + $html->assertElementNotContains('.page-content', 'data:'); + $html->assertElementNotContains('.page-content', 'base64'); + } + } + + public function test_javascript_uri_links_are_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + 'withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:'); + } + } + + public function test_form_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', ''); + $page = $this->entities->page(); + $page->html = '
'; + $page->save(); + + $this->asEditor()->get($page->getUrl())->assertSee('dont-see-this', false); + + config()->set('app.content_filtering', 'f'); + $this->get($page->getUrl())->assertDontSee('dont-see-this', false); + } + + public function test_form_actions_with_javascript_are_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '', + 'Click me', + 'Click me', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee('id="xss"', false); + $pageView->assertDontSee('action=javascript:', false); + $pageView->assertDontSee('action=JaVaScRiPt:', false); + $pageView->assertDontSee('formaction=javascript:', false); + $pageView->assertDontSee('formaction=JaVaScRiPt:', false); + } + } + + public function test_form_elements_are_removed() + { + config()->set('app.content_filtering', 'f'); + + $checks = [ + '

thisisacattofind

thisdogshouldnotbefound
', + '

thisisacattofind

', + '

thisisacattofind

', + '

thisisacattofind

', + '

thisisacattofind

thisdogshouldnotbefound
', + '

thisisacattofind

', + '

thisisacattofind

', + <<<'TESTCASE' + + + + +

thisisacattofind

+
+

thisdogshouldnotbefound

+
+ + + + +
+
+TESTCASE + + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertSee('thisisacattofind'); + $pageView->assertDontSee('thisdogshouldnotbefound'); + } + } + + public function test_form_attributes_are_removed() + { + config()->set('app.content_filtering', 'f'); + + $withinSvgSample = <<<'TESTCASE' + + + + +

thisisacattofind

+

thisisacattofind

+ + +
+
+TESTCASE; + + $checks = [ + 'formaction' => '

thisisacattofind

', + 'form' => '

thisisacattofind

', + 'formmethod' => '

thisisacattofind

', + 'formtarget' => '

thisisacattofind

', + 'FORMTARGET' => '

thisisacattofind

', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $attribute => $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertSee('thisisacattofind'); + $this->withHtml($pageView)->assertElementNotExists(".page-content [{$attribute}]"); + } + + $page->html = $withinSvgSample; + $page->save(); + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + foreach ($checks as $attribute => $check) { + $pageView->assertSee('thisisacattofind'); + $html->assertElementNotExists(".page-content [{$attribute}]"); + } + } + + public function test_metadata_redirects_are_removed() + { + config()->set('app.content_filtering', 'h'); + + $checks = [ + '', + '', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'content='); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'external_url'); + } + } + + public function test_page_inline_on_attributes_removed_by_default() + { + config()->set('app.content_filtering', 'j'); + + $this->asEditor(); + $page = $this->entities->page(); + $script = '

Hello

'; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $pageView->assertDontSee($script, false); + $pageView->assertSee('

Hello

', false); + } + + public function test_more_complex_inline_on_attributes_escaping_scenarios() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '

Hello

', + '

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
Lorem ipsum dolor sit amet.

Hello

', + '
xss link\', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $this->withHtml($pageView)->assertElementNotContains('.page-content', 'onclick'); + } + } + + public function test_page_content_scripts_show_with_filters_disabled() + { + $this->asEditor(); + $page = $this->entities->page(); + config()->set('app.content_filtering', ''); + + $script = 'abc123abc123'; + $page->html = "no escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee($script, false); + $pageView->assertDontSee('abc123abc123'); + } + + public function test_svg_script_usage_is_removed() + { + config()->set('app.content_filtering', 'j'); + + $checks = [ + '', + '', + '', + '', + '', + 'XSS', + 'XSS', + '', + ]; + + $this->asEditor(); + $page = $this->entities->page(); + + foreach ($checks as $check) { + $page->html = $check; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertStatus(200); + $html = $this->withHtml($pageView); + $html->assertElementNotContains('.page-content', 'alert'); + $html->assertElementNotContains('.page-content', 'xlink:href'); + $html->assertElementNotContains('.page-content', 'application/xml'); + $html->assertElementNotContains('.page-content', 'javascript'); + } + } + + public function test_page_inline_on_attributes_show_with_filters_disabled() + { + $this->asEditor(); + $page = $this->entities->page(); + config()->set('app.content_filtering', ''); + + $script = '

Hello

'; + $page->html = "escape {$script}"; + $page->save(); + + $pageView = $this->get($page->getUrl()); + $pageView->assertSee($script, false); + $pageView->assertDontSee('

Hello

', false); + } + + public function test_non_content_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', ''); + $page = $this->entities->page(); + $html = <<<'HTML' + + +HTML; + $page->html = $html; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('superbeans', false); + + config()->set('app.content_filtering', 'h'); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertDontSee('superbeans', false); + } + + public function test_non_content_filtering() + { + config()->set('app.content_filtering', 'h'); + $page = $this->entities->page(); + $html = <<<'HTML' + +

inbetweenpsection

+ + +superbeans! + +HTML; + + $page->html = $html; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertDontSee('superbeans', false); + $resp->assertSee('inbetweenpsection', false); + } + + public function test_allow_list_filtering_is_controlled_by_config() + { + config()->set('app.content_filtering', ''); + $page = $this->entities->page(); + $page->html = '
Hello!
'; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl()); + $resp->assertSee('style="position: absolute; left: 0;color:#00FFEE;"', false); + + config()->set('app.content_filtering', 'a'); + $resp = $this->get($page->getUrl()); + $resp->assertDontSee('style="position: absolute; left: 0;color:#00FFEE;"', false); + $resp->assertSee('style="color:#00FFEE;"', false); + } + + public function test_allow_list_style_filtering() + { + $testCasesExpectedByInput = [ + '
Hello!
' => '
Hello!
', + '
Hello!
' => '
Hello!
', + '
Hello!
' => '
Hello!
', + '
Hello!
' => '
Hello!
', + ]; + + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input => $expected) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee($expected, false); + } + } + + public function test_allow_list_does_not_filter_cases() + { + $testCasesExpectedByInput = [ + '

New tab linkydoodle

', + '

@mentionusertext

', + '
Hello

Mydetailshere

', + ]; + + config()->set('app.content_filtering', 'a'); + $page = $this->entities->page(); + $this->asEditor(); + + foreach ($testCasesExpectedByInput as $input) { + $page->html = $input; + $page->save(); + $resp = $this->get($page->getUrl()); + + $resp->assertSee($input, false); + } + } +} diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 23a38b5735b..deae153e192 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -101,261 +101,6 @@ public function test_page_includes_to_nonexisting_pages_does_not_error() $pageResp->assertSee('Hello Barry'); } - public function test_page_content_scripts_removed_by_default() - { - $this->asEditor(); - $page = $this->entities->page(); - $script = 'abc123abc123'; - $page->html = "escape {$script}"; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $pageView->assertDontSee($script, false); - $pageView->assertSee('abc123abc123'); - } - - public function test_more_complex_content_script_escaping_scenarios() - { - $checks = [ - "

Some script

", - "

Some script

", - "

Some script

", - "

Some script

", - "

Some script

", - "

Some script

", - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $this->withHtml($pageView)->assertElementNotContains('.page-content', ''); - } - } - - public function test_js_and_base64_src_urls_are_removed() - { - $checks = [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $html = $this->withHtml($pageView); - $html->assertElementNotContains('.page-content', ''); - $html->assertElementNotContains('.page-content', 'src='); - $html->assertElementNotContains('.page-content', 'javascript:'); - $html->assertElementNotContains('.page-content', 'data:'); - $html->assertElementNotContains('.page-content', 'base64'); - } - } - - public function test_javascript_uri_links_are_removed() - { - $checks = [ - 'withHtml($pageView)->assertElementNotContains('.page-content', 'href=javascript:'); - } - } - - public function test_form_actions_with_javascript_are_removed() - { - $checks = [ - '
', - '
', - '
', - '
', - '
', - ]; - - $this->asEditor(); - $page = $this->entities->page(); - - foreach ($checks as $check) { - $page->html = $check; - $page->save(); - - $pageView = $this->get($page->getUrl()); - $pageView->assertStatus(200); - $this->withHtml($pageView)->assertElementNotContains('.page-content', '