@if($activity->entity)
-
+ @include('entities.icon-link', ['entity' => $activity->entity])
@elseif($activity->detail && $activity->isForEntity())
@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
index 25b7a14fc..8694ce86d 100644
--- a/resources/views/shelves/show.blade.php
+++ b/resources/views/shelves/show.blade.php
@@ -79,7 +79,7 @@
{{ trans('common.details') }}
- @include('entities.meta', ['entity' => $shelf])
+ @include('entities.meta', ['entity' => $shelf, 'watchOptions' => null])
@if($shelf->hasPermissions())
@if(userCan('restrictions-manage', $shelf))
@@ -99,7 +99,7 @@
@if(count($activity) > 0)
-
+
{{ trans('entities.recent_activity') }}
@include('common.activity-list', ['activity' => $activity])
@@ -143,7 +143,7 @@
@endif
- @if(signedInUser())
+ @if(!user()->isGuest())
@include('entities.favourite-action', ['entity' => $shelf])
@endif
diff --git a/resources/views/users/create.blade.php b/resources/views/users/create.blade.php
index 540d7bd6a..dafc623e1 100644
--- a/resources/views/users/create.blade.php
+++ b/resources/views/users/create.blade.php
@@ -14,7 +14,7 @@
@include('users.parts.form')
- @include('users.parts.language-option-row', ['value' => old('setting.language') ?? config('app.default_locale')])
+ @include('users.parts.language-option-row', ['value' => old('language') ?? config('app.default_locale')])
diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php
index 4e31e785d..832186930 100644
--- a/resources/views/users/edit.blade.php
+++ b/resources/views/users/edit.blade.php
@@ -16,7 +16,8 @@
-
+
{{ trans('settings.users_avatar_desc') }}
@@ -33,13 +34,15 @@
- @include('users.parts.language-option-row', ['value' => setting()->getUser($user, 'language', config('app.default_locale'))])
+ @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()])
@@ -60,7 +63,8 @@
@@ -84,7 +88,8 @@
class="button small outline">{{ trans('settings.users_social_disconnect') }}
@else
-
{{ trans('settings.users_social_connect') }}
@endif
diff --git a/resources/views/users/preferences/index.blade.php b/resources/views/users/preferences/index.blade.php
new file mode 100644
index 000000000..f8576ed9e
--- /dev/null
+++ b/resources/views/users/preferences/index.blade.php
@@ -0,0 +1,41 @@
+@extends('layouts.simple')
+
+@section('body')
+
+
+
+
+
{{ trans('preferences.shortcuts_interface') }}
+
{{ trans('preferences.shortcuts_overview_desc') }}
+
+
+
+
+ @if(!user()->isGuest() && userCan('receive-notifications'))
+
+
+
{{ trans('preferences.notifications') }}
+
{{ trans('preferences.notifications_desc') }}
+
+
+
+ @endif
+
+ @if(!user()->isGuest())
+
+
+
{{ trans('settings.users_edit_profile') }}
+
{{ trans('preferences.profile_overview_desc') }}
+
+
+
+ @endif
+
+
+@stop
diff --git a/resources/views/users/preferences/notifications.blade.php b/resources/views/users/preferences/notifications.blade.php
new file mode 100644
index 000000000..9817aac4d
--- /dev/null
+++ b/resources/views/users/preferences/notifications.blade.php
@@ -0,0 +1,75 @@
+@extends('layouts.simple')
+
+@section('body')
+
+
+
+
+
+ {{ trans('preferences.notifications_watched') }}
+ {{ trans('preferences.notifications_watched_desc') }}
+
+ @if($watches->isEmpty())
+ {{ trans('common.no_items') }}
+ @else
+
+ @foreach($watches as $watch)
+
+
+ @include('entities.icon-link', ['entity' => $watch->watchable])
+
+
+ @icon('watch' . ($watch->ignoring() ? '-ignore' : ''))
+ {{ trans('entities.watch_title_' . $watch->getLevelName()) }}
+
+
+ @endforeach
+
+ @endif
+
+ {{ $watches->links() }}
+
+
+
+@stop
diff --git a/resources/views/vendor/notifications/email.blade.php b/resources/views/vendor/notifications/email.blade.php
index f73b87b59..8e922fba5 100644
--- a/resources/views/vendor/notifications/email.blade.php
+++ b/resources/views/vendor/notifications/email.blade.php
@@ -1,5 +1,5 @@
-
+
@@ -30,7 +30,7 @@
$style = [
/* Layout ------------------------------ */
- 'body' => 'margin: 0; padding: 0; width: 100%; background-color: #F2F4F6;',
+ 'body' => 'margin: 0; padding: 0; width: 100%; background-color: #F2F4F6;color:#444444;',
'email-wrapper' => 'width: 100%; margin: 0; padding: 0; background-color: #F2F4F6;',
/* Masthead ----------------------- */
@@ -54,8 +54,8 @@ $style = [
'anchor' => 'color: '.setting('app-color').';overflow-wrap: break-word;word-wrap: break-word;word-break: break-all;word-break:break-word;',
'header-1' => 'margin-top: 0; color: #2F3133; font-size: 19px; font-weight: bold; text-align: left;',
- 'paragraph' => 'margin-top: 0; color: #74787E; font-size: 16px; line-height: 1.5em;',
- 'paragraph-sub' => 'margin-top: 0; color: #74787E; font-size: 12px; line-height: 1.5em;',
+ 'paragraph' => 'margin-top: 0; color: #444444; font-size: 16px; line-height: 1.5em;',
+ 'paragraph-sub' => 'margin-top: 0; color: #444444; font-size: 12px; line-height: 1.5em;',
'paragraph-center' => 'text-align: center;',
/* Buttons ------------------------------ */
@@ -147,7 +147,7 @@ $style = [
@foreach ($outroLines as $line)
-
+
{{ $line }}
@endforeach
@@ -159,7 +159,7 @@ $style = [
- {{ trans('common.email_action_help', ['actionText' => $actionText]) }}
+ {{ $locale->trans('common.email_action_help', ['actionText' => $actionText]) }}
@@ -187,7 +187,7 @@ $style = [
© {{ date('Y') }}
{{ setting('app-name') }}.
- {{ trans('common.email_rights') }}
+ {{ $locale->trans('common.email_rights') }}
|
diff --git a/routes/web.php b/routes/web.php
index 6e80635e0..8116cdaf8 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -195,6 +195,9 @@ Route::middleware('auth')->group(function () {
Route::post('/favourites/add', [ActivityControllers\FavouriteController::class, 'add']);
Route::post('/favourites/remove', [ActivityControllers\FavouriteController::class, 'remove']);
+ // Watching
+ Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']);
+
// Other Pages
Route::get('/', [HomeController::class, 'index']);
Route::get('/home', [HomeController::class, 'index']);
@@ -229,9 +232,11 @@ Route::middleware('auth')->group(function () {
Route::delete('/settings/users/{id}', [UserControllers\UserController::class, 'destroy']);
// User Preferences
- Route::redirect('/preferences', '/');
+ Route::get('/preferences', [UserControllers\UserPreferencesController::class, 'index']);
Route::get('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'showShortcuts']);
Route::put('/preferences/shortcuts', [UserControllers\UserPreferencesController::class, 'updateShortcuts']);
+ Route::get('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'showNotifications']);
+ Route::put('/preferences/notifications', [UserControllers\UserPreferencesController::class, 'updateNotifications']);
Route::patch('/preferences/change-view/{type}', [UserControllers\UserPreferencesController::class, 'changeView']);
Route::patch('/preferences/change-sort/{type}', [UserControllers\UserPreferencesController::class, 'changeSort']);
Route::patch('/preferences/change-expansion/{type}', [UserControllers\UserPreferencesController::class, 'changeExpansion']);
diff --git a/tests/Actions/WebhookCallTest.php b/tests/Actions/WebhookCallTest.php
index fc49a524e..81bd7e7e8 100644
--- a/tests/Actions/WebhookCallTest.php
+++ b/tests/Actions/WebhookCallTest.php
@@ -7,11 +7,10 @@ use BookStack\Activity\DispatchWebhookJob;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Api\ApiToken;
-use BookStack\Entities\Models\PageRevision;
use BookStack\Users\Models\User;
-use Illuminate\Http\Client\Request;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
-use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class WebhookCallTest extends TestCase
@@ -50,10 +49,10 @@ class WebhookCallTest extends TestCase
public function test_webhook_runs_for_delete_actions()
{
+ // This test must not fake the queue/bus since this covers an issue
+ // around handling and serialization of items now deleted from the database.
$this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
- Http::fake([
- '*' => Http::response('', 500),
- ]);
+ $this->mockHttpClient([new Response(500)]);
$user = $this->users->newUser();
$resp = $this->asAdmin()->delete($user->getEditUrl());
@@ -69,9 +68,7 @@ class WebhookCallTest extends TestCase
public function test_failed_webhook_call_logs_error()
{
$logger = $this->withTestLogger();
- Http::fake([
- '*' => Http::response('', 500),
- ]);
+ $this->mockHttpClient([new Response(500)]);
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
$this->assertNull($webhook->last_errored_at);
@@ -86,7 +83,7 @@ class WebhookCallTest extends TestCase
public function test_webhook_call_exception_is_caught_and_logged()
{
- Http::shouldReceive('asJson')->andThrow(new \Exception('Failed to perform request'));
+ $this->mockHttpClient([new ConnectException('Failed to perform request', new \GuzzleHttp\Psr7\Request('GET', ''))]);
$logger = $this->withTestLogger();
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
@@ -101,31 +98,40 @@ class WebhookCallTest extends TestCase
$this->assertNotNull($webhook->last_errored_at);
}
+ public function test_webhook_uses_ssr_hosts_option_if_set()
+ {
+ config()->set('app.ssr_hosts', 'https://*.example.com');
+ $responses = $this->mockHttpClient();
+
+ $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.co.uk'], ['all']);
+ $this->runEvent(ActivityType::ROLE_CREATE);
+ $this->assertEquals(0, $responses->requestCount());
+
+ $webhook->refresh();
+ $this->assertEquals('The URL does not match the configured allowed SSR hosts', $webhook->last_error);
+ $this->assertNotNull($webhook->last_errored_at);
+ }
+
public function test_webhook_call_data_format()
{
- Http::fake([
- '*' => Http::response('', 200),
- ]);
+ $responses = $this->mockHttpClient([new Response(200, [], '')]);
$webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
$page = $this->entities->page();
$editor = $this->users->editor();
$this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor);
- Http::assertSent(function (Request $request) use ($editor, $page, $webhook) {
- $reqData = $request->data();
-
- return $request->isJson()
- && $reqData['event'] === 'page_update'
- && $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"')
- && is_string($reqData['triggered_at'])
- && $reqData['triggered_by']['name'] === $editor->name
- && $reqData['triggered_by_profile_url'] === $editor->getProfileUrl()
- && $reqData['webhook_id'] === $webhook->id
- && $reqData['webhook_name'] === $webhook->name
- && $reqData['url'] === $page->getUrl()
- && $reqData['related_item']['name'] === $page->name;
- });
+ $request = $responses->latestRequest();
+ $reqData = json_decode($request->getBody(), true);
+ $this->assertEquals('page_update', $reqData['event']);
+ $this->assertEquals(($editor->name . ' updated page "' . $page->name . '"'), $reqData['text']);
+ $this->assertIsString($reqData['triggered_at']);
+ $this->assertEquals($editor->name, $reqData['triggered_by']['name']);
+ $this->assertEquals($editor->getProfileUrl(), $reqData['triggered_by_profile_url']);
+ $this->assertEquals($webhook->id, $reqData['webhook_id']);
+ $this->assertEquals($webhook->name, $reqData['webhook_name']);
+ $this->assertEquals($page->getUrl(), $reqData['url']);
+ $this->assertEquals($page->name, $reqData['related_item']['name']);
}
protected function runEvent(string $event, $detail = '', ?User $user = null)
diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php
new file mode 100644
index 000000000..5b9ae5a4c
--- /dev/null
+++ b/tests/Activity/WatchTest.php
@@ -0,0 +1,408 @@
+users->editor();
+ $this->actingAs($editor);
+
+ $entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()];
+ /** @var Entity $entity */
+ foreach ($entities as $entity) {
+ $resp = $this->get($entity->getUrl());
+ $this->withHtml($resp)->assertElementContains('form[action$="/watching/update"] button.icon-list-item', 'Watch');
+
+ $watchOptions = new UserEntityWatchOptions($editor, $entity);
+ $watchOptions->updateLevelByValue(WatchLevels::COMMENTS);
+
+ $resp = $this->get($entity->getUrl());
+ $this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item');
+ }
+ }
+
+ public function test_watch_action_only_shows_with_permission()
+ {
+ $viewer = $this->users->viewer();
+ $this->actingAs($viewer);
+
+ $entities = [$this->entities->book(), $this->entities->chapter(), $this->entities->page()];
+ /** @var Entity $entity */
+ foreach ($entities as $entity) {
+ $resp = $this->get($entity->getUrl());
+ $this->withHtml($resp)->assertElementNotExists('form[action$="/watching/update"] button.icon-list-item');
+ }
+
+ $this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']);
+
+ /** @var Entity $entity */
+ foreach ($entities as $entity) {
+ $resp = $this->get($entity->getUrl());
+ $this->withHtml($resp)->assertElementExists('form[action$="/watching/update"] button.icon-list-item');
+ }
+ }
+
+ public function test_watch_update()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->book();
+
+ $this->actingAs($editor)->get($book->getUrl());
+ $resp = $this->put('/watching/update', [
+ 'type' => $book->getMorphClass(),
+ 'id' => $book->id,
+ 'level' => 'comments'
+ ]);
+
+ $resp->assertRedirect($book->getUrl());
+ $this->assertSessionHas('success');
+ $this->assertDatabaseHas('watches', [
+ 'watchable_id' => $book->id,
+ 'watchable_type' => $book->getMorphClass(),
+ 'user_id' => $editor->id,
+ 'level' => WatchLevels::COMMENTS,
+ ]);
+
+ $resp = $this->put('/watching/update', [
+ 'type' => $book->getMorphClass(),
+ 'id' => $book->id,
+ 'level' => 'default'
+ ]);
+ $resp->assertRedirect($book->getUrl());
+ $this->assertDatabaseMissing('watches', [
+ 'watchable_id' => $book->id,
+ 'watchable_type' => $book->getMorphClass(),
+ 'user_id' => $editor->id,
+ ]);
+ }
+
+ public function test_watch_update_fails_for_guest()
+ {
+ $this->setSettings(['app-public' => 'true']);
+ $guest = $this->users->guest();
+ $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']);
+ $book = $this->entities->book();
+
+ $resp = $this->put('/watching/update', [
+ 'type' => $book->getMorphClass(),
+ 'id' => $book->id,
+ 'level' => 'comments'
+ ]);
+
+ $this->assertPermissionError($resp);
+ $guest->unsetRelations();
+ }
+
+ public function test_watch_detail_display_reflects_state()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->bookHasChaptersAndPages();
+ $chapter = $book->chapters()->first();
+ $page = $chapter->pages()->first();
+
+ (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::UPDATES);
+
+ $this->actingAs($editor)->get($book->getUrl())->assertSee('Watching new pages and updates');
+ $this->get($chapter->getUrl())->assertSee('Watching via parent book');
+ $this->get($page->getUrl())->assertSee('Watching via parent book');
+
+ (new UserEntityWatchOptions($editor, $chapter))->updateLevelByValue(WatchLevels::COMMENTS);
+ $this->get($chapter->getUrl())->assertSee('Watching new pages, updates & comments');
+ $this->get($page->getUrl())->assertSee('Watching via parent chapter');
+
+ (new UserEntityWatchOptions($editor, $page))->updateLevelByValue(WatchLevels::UPDATES);
+ $this->get($page->getUrl())->assertSee('Watching new pages and updates');
+ }
+
+ public function test_watch_detail_ignore_indicator_cascades()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->bookHasChaptersAndPages();
+ (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE);
+
+ $this->actingAs($editor)->get($book->getUrl())->assertSee('Ignoring notifications');
+ $this->get($book->chapters()->first()->getUrl())->assertSee('Ignoring via parent book');
+ $this->get($book->pages()->first()->getUrl())->assertSee('Ignoring via parent book');
+ }
+
+ public function test_watch_option_menu_shows_current_active_state()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->book();
+ $options = new UserEntityWatchOptions($editor, $book);
+
+ $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
+ $respHtml->assertElementNotExists('form[action$="/watching/update"] svg[data-icon="check-circle"]');
+
+ $options->updateLevelByValue(WatchLevels::COMMENTS);
+ $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
+ $respHtml->assertElementExists('form[action$="/watching/update"] button[value="comments"] svg[data-icon="check-circle"]');
+
+ $options->updateLevelByValue(WatchLevels::IGNORE);
+ $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
+ $respHtml->assertElementExists('form[action$="/watching/update"] button[value="ignore"] svg[data-icon="check-circle"]');
+ }
+
+ public function test_watch_option_menu_limits_options_for_pages()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->bookHasChaptersAndPages();
+ (new UserEntityWatchOptions($editor, $book))->updateLevelByValue(WatchLevels::IGNORE);
+
+ $respHtml = $this->withHtml($this->actingAs($editor)->get($book->getUrl()));
+ $respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="new"]');
+
+ $respHtml = $this->withHtml($this->get($book->pages()->first()->getUrl()));
+ $respHtml->assertElementExists('form[action$="/watching/update"] button[name="level"][value="updates"]');
+ $respHtml->assertElementNotExists('form[action$="/watching/update"] button[name="level"][value="new"]');
+ }
+
+ public function test_notify_own_page_changes()
+ {
+ $editor = $this->users->editor();
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $prefs = new UserNotificationPreferences($editor);
+ $prefs->updateFromSettingsArray(['own-page-changes' => 'true']);
+
+ $notifications = Notification::fake();
+
+ $this->asAdmin();
+ $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']);
+ $notifications->assertSentTo($editor, PageUpdateNotification::class);
+ }
+
+ public function test_notify_own_page_comments()
+ {
+ $editor = $this->users->editor();
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $prefs = new UserNotificationPreferences($editor);
+ $prefs->updateFromSettingsArray(['own-page-comments' => 'true']);
+
+ $notifications = Notification::fake();
+
+ $this->asAdmin()->post("/comment/{$entities['page']->id}", [
+ 'text' => 'My new comment'
+ ]);
+ $notifications->assertSentTo($editor, CommentCreationNotification::class);
+ }
+
+ public function test_notify_comment_replies()
+ {
+ $editor = $this->users->editor();
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $prefs = new UserNotificationPreferences($editor);
+ $prefs->updateFromSettingsArray(['comment-replies' => 'true']);
+
+ // Create some existing comments to pad IDs to help potentially error
+ // on mis-identification of parent via ids used.
+ Comment::factory()->count(5)
+ ->for($entities['page'], 'entity')
+ ->create(['created_by' => $this->users->admin()->id]);
+
+ $notifications = Notification::fake();
+
+ $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [
+ 'text' => 'My new comment'
+ ]);
+ $comment = $entities['page']->comments()->orderBy('id', 'desc')->first();
+
+ $this->asAdmin()->post("/comment/{$entities['page']->id}", [
+ 'text' => 'My new comment response',
+ 'parent_id' => $comment->local_id,
+ ]);
+ $notifications->assertSentTo($editor, CommentCreationNotification::class);
+ }
+
+ public function test_notify_watch_parent_book_ignore()
+ {
+ $editor = $this->users->editor();
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $watches = new UserEntityWatchOptions($editor, $entities['book']);
+ $prefs = new UserNotificationPreferences($editor);
+ $watches->updateLevelByValue(WatchLevels::IGNORE);
+ $prefs->updateFromSettingsArray(['own-page-changes' => 'true', 'own-page-comments' => true]);
+
+ $notifications = Notification::fake();
+
+ $this->asAdmin()->post("/comment/{$entities['page']->id}", [
+ 'text' => 'My new comment response',
+ ]);
+ $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']);
+ $notifications->assertNothingSent();
+ }
+
+ public function test_notify_watch_parent_book_comments()
+ {
+ $notifications = Notification::fake();
+ $editor = $this->users->editor();
+ $admin = $this->users->admin();
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $watches = new UserEntityWatchOptions($editor, $entities['book']);
+ $watches->updateLevelByValue(WatchLevels::COMMENTS);
+
+ // Comment post
+ $this->actingAs($admin)->post("/comment/{$entities['page']->id}", [
+ 'text' => 'My new comment response',
+ ]);
+
+ $notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) {
+ $mail = $notification->toMail($editor);
+ $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
+ return $mail->subject === 'New comment on page: ' . $entities['page']->getShortName()
+ && str_contains($mailContent, 'View Comment')
+ && str_contains($mailContent, 'Page Name: ' . $entities['page']->name)
+ && str_contains($mailContent, 'Commenter: ' . $admin->name)
+ && str_contains($mailContent, 'Comment: My new comment response');
+ });
+ }
+
+ public function test_notify_watch_parent_book_updates()
+ {
+ $notifications = Notification::fake();
+ $editor = $this->users->editor();
+ $admin = $this->users->admin();
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $watches = new UserEntityWatchOptions($editor, $entities['book']);
+ $watches->updateLevelByValue(WatchLevels::UPDATES);
+
+ $this->actingAs($admin);
+ $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']);
+
+ $notifications->assertSentTo($editor, function (PageUpdateNotification $notification) use ($editor, $admin) {
+ $mail = $notification->toMail($editor);
+ $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
+ return $mail->subject === 'Updated page: Updated page'
+ && str_contains($mailContent, 'View Page')
+ && str_contains($mailContent, 'Page Name: Updated page')
+ && str_contains($mailContent, 'Updated By: ' . $admin->name)
+ && str_contains($mailContent, 'you won\'t be sent notifications for further edits to this page by the same editor');
+ });
+
+ // Test debounce
+ $notifications = Notification::fake();
+ $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']);
+ $notifications->assertNothingSentTo($editor);
+ }
+
+ public function test_notify_watch_parent_book_new()
+ {
+ $notifications = Notification::fake();
+ $editor = $this->users->editor();
+ $admin = $this->users->admin();
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $watches = new UserEntityWatchOptions($editor, $entities['book']);
+ $watches->updateLevelByValue(WatchLevels::NEW);
+
+ $this->actingAs($admin)->get($entities['chapter']->getUrl('/create-page'));
+ $page = $entities['chapter']->pages()->where('draft', '=', true)->first();
+ $this->post($page->getUrl(), ['name' => 'My new page', 'html' => 'My new page content']);
+
+ $notifications->assertSentTo($editor, function (PageCreationNotification $notification) use ($editor, $admin) {
+ $mail = $notification->toMail($editor);
+ $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
+ return $mail->subject === 'New page: My new page'
+ && str_contains($mailContent, 'View Page')
+ && str_contains($mailContent, 'Page Name: My new page')
+ && str_contains($mailContent, 'Created By: ' . $admin->name);
+ });
+ }
+
+ public function test_notifications_sent_in_right_language()
+ {
+ $editor = $this->users->editor();
+ $admin = $this->users->admin();
+ setting()->putUser($editor, 'language', 'de');
+ $entities = $this->entities->createChainBelongingToUser($editor);
+ $watches = new UserEntityWatchOptions($editor, $entities['book']);
+ $watches->updateLevelByValue(WatchLevels::COMMENTS);
+
+ $activities = [
+ ActivityType::PAGE_CREATE => $entities['page'],
+ ActivityType::PAGE_UPDATE => $entities['page'],
+ ActivityType::COMMENT_CREATE => (new Comment([]))->forceFill(['entity_id' => $entities['page']->id, 'entity_type' => $entities['page']->getMorphClass()]),
+ ];
+
+ $notifications = Notification::fake();
+ $logger = app()->make(ActivityLogger::class);
+ $this->actingAs($admin);
+
+ foreach ($activities as $activityType => $detail) {
+ $logger->add($activityType, $detail);
+ }
+
+ $sent = $notifications->sentNotifications()[get_class($editor)][$editor->id];
+ $this->assertCount(3, $sent);
+
+ foreach ($sent as $notificationInfo) {
+ $notification = $notificationInfo[0]['notification'];
+ $this->assertInstanceOf(BaseActivityNotification::class, $notification);
+ $mail = $notification->toMail($editor);
+ $mailContent = html_entity_decode(strip_tags($mail->render()), ENT_QUOTES);
+ $this->assertStringContainsString('Name der Seite:', $mailContent);
+ $this->assertStringContainsString('Diese Benachrichtigung wurde', $mailContent);
+ $this->assertStringContainsString('Sollte es beim Anklicken der Schaltfläche', $mailContent);
+ }
+ }
+
+ public function test_notifications_not_sent_if_lacking_view_permission_for_related_item()
+ {
+ $notifications = Notification::fake();
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $watches = new UserEntityWatchOptions($editor, $page);
+ $watches->updateLevelByValue(WatchLevels::COMMENTS);
+ $this->permissions->disableEntityInheritedPermissions($page);
+
+ $this->asAdmin()->post("/comment/{$page->id}", [
+ 'text' => 'My new comment response',
+ ])->assertOk();
+
+ $notifications->assertNothingSentTo($editor);
+ }
+
+ public function test_watches_deleted_on_user_delete()
+ {
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $watches = new UserEntityWatchOptions($editor, $page);
+ $watches->updateLevelByValue(WatchLevels::COMMENTS);
+ $this->assertDatabaseHas('watches', ['user_id' => $editor->id]);
+
+ $this->asAdmin()->delete($editor->getEditUrl());
+
+ $this->assertDatabaseMissing('watches', ['user_id' => $editor->id]);
+ }
+
+ public function test_watches_deleted_on_item_delete()
+ {
+ $editor = $this->users->editor();
+ $page = $this->entities->page();
+
+ $watches = new UserEntityWatchOptions($editor, $page);
+ $watches->updateLevelByValue(WatchLevels::COMMENTS);
+ $this->assertDatabaseHas('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
+
+ $this->entities->destroy($page);
+
+ $this->assertDatabaseMissing('watches', ['watchable_type' => 'page', 'watchable_id' => $page->id]);
+ }
+}
diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php
index 713d8bba4..0629f3aed 100644
--- a/tests/Api/ChaptersApiTest.php
+++ b/tests/Api/ChaptersApiTest.php
@@ -45,6 +45,7 @@ class ChaptersApiTest extends TestCase
'value' => 'tagvalue',
],
],
+ 'priority' => 15,
];
$resp = $this->postJson($this->baseEndpoint, $details);
@@ -137,6 +138,7 @@ class ChaptersApiTest extends TestCase
'value' => 'freshtagval',
],
],
+ 'priority' => 15,
];
$resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details);
diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php
index 4a81f738b..0d084472d 100644
--- a/tests/Api/PagesApiTest.php
+++ b/tests/Api/PagesApiTest.php
@@ -45,6 +45,7 @@ class PagesApiTest extends TestCase
'value' => 'tagvalue',
],
],
+ 'priority' => 15,
];
$resp = $this->postJson($this->baseEndpoint, $details);
@@ -207,6 +208,7 @@ class PagesApiTest extends TestCase
'value' => 'freshtagval',
],
],
+ 'priority' => 15,
];
$resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
diff --git a/tests/Api/UsersApiTest.php b/tests/Api/UsersApiTest.php
index e2a04b528..6ad727257 100644
--- a/tests/Api/UsersApiTest.php
+++ b/tests/Api/UsersApiTest.php
@@ -2,11 +2,11 @@
namespace Tests\Api;
+use BookStack\Access\Notifications\UserInviteNotification;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity as ActivityModel;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity;
-use BookStack\Notifications\UserInvite;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Hash;
@@ -140,7 +140,7 @@ class UsersApiTest extends TestCase
$resp->assertStatus(200);
/** @var User $user */
$user = User::query()->where('email', '=', 'bboris@example.com')->first();
- Notification::assertSentTo($user, UserInvite::class);
+ Notification::assertSentTo($user, UserInviteNotification::class);
}
public function test_create_name_and_email_validation()
diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php
index 191a25f88..204a3bb5f 100644
--- a/tests/Auth/OidcTest.php
+++ b/tests/Auth/OidcTest.php
@@ -7,7 +7,6 @@ use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
-use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use Illuminate\Testing\TestResponse;
use Tests\Helpers\OidcJwtHelper;
@@ -31,7 +30,7 @@ class OidcTest extends TestCase
'auth.method' => 'oidc',
'auth.defaults.guard' => 'oidc',
'oidc.name' => 'SingleSignOn-Testing',
- 'oidc.display_name_claims' => ['name'],
+ 'oidc.display_name_claims' => 'name',
'oidc.client_id' => OidcJwtHelper::defaultClientId(),
'oidc.client_secret' => 'testpass',
'oidc.jwt_public_key' => $this->keyFilePath,
@@ -137,7 +136,7 @@ class OidcTest extends TestCase
$this->post('/oidc/login');
$state = session()->get('oidc_state');
- $transactions = &$this->mockHttpClient([$this->getMockAuthorizationResponse([
+ $transactions = $this->mockHttpClient([$this->getMockAuthorizationResponse([
'email' => 'benny@example.com',
'sub' => 'benny1010101',
])]);
@@ -146,9 +145,8 @@ class OidcTest extends TestCase
// App calls token endpoint to get id token
$resp = $this->get('/oidc/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=' . $state);
$resp->assertRedirect('/');
- $this->assertCount(1, $transactions);
- /** @var Request $tokenRequest */
- $tokenRequest = $transactions[0]['request'];
+ $this->assertEquals(1, $transactions->requestCount());
+ $tokenRequest = $transactions->latestRequest();
$this->assertEquals('https://oidc.local/token', (string) $tokenRequest->getUri());
$this->assertEquals('POST', $tokenRequest->getMethod());
$this->assertEquals('Basic ' . base64_encode(OidcJwtHelper::defaultClientId() . ':testpass'), $tokenRequest->getHeader('Authorization')[0]);
@@ -279,7 +277,7 @@ class OidcTest extends TestCase
{
$this->withAutodiscovery();
- $transactions = &$this->mockHttpClient([
+ $transactions = $this->mockHttpClient([
$this->getAutoDiscoveryResponse(),
$this->getJwksResponse(),
]);
@@ -289,11 +287,9 @@ class OidcTest extends TestCase
$this->runLogin();
$this->assertTrue(auth()->check());
- /** @var Request $discoverRequest */
- $discoverRequest = $transactions[0]['request'];
- /** @var Request $discoverRequest */
- $keysRequest = $transactions[1]['request'];
+ $discoverRequest = $transactions->requestAt(0);
+ $keysRequest = $transactions->requestAt(1);
$this->assertEquals('GET', $keysRequest->getMethod());
$this->assertEquals('GET', $discoverRequest->getMethod());
$this->assertEquals(OidcJwtHelper::defaultIssuer() . '/.well-known/openid-configuration', $discoverRequest->getUri());
@@ -316,7 +312,7 @@ class OidcTest extends TestCase
{
$this->withAutodiscovery();
- $transactions = &$this->mockHttpClient([
+ $transactions = $this->mockHttpClient([
$this->getAutoDiscoveryResponse(),
$this->getJwksResponse(),
$this->getAutoDiscoveryResponse([
@@ -327,15 +323,15 @@ class OidcTest extends TestCase
// Initial run
$this->post('/oidc/login');
- $this->assertCount(2, $transactions);
+ $this->assertEquals(2, $transactions->requestCount());
// Second run, hits cache
$this->post('/oidc/login');
- $this->assertCount(2, $transactions);
+ $this->assertEquals(2, $transactions->requestCount());
// Third run, different issuer, new cache key
config()->set(['oidc.issuer' => 'https://auto.example.com']);
$this->post('/oidc/login');
- $this->assertCount(4, $transactions);
+ $this->assertEquals(4, $transactions->requestCount());
}
public function test_auth_login_with_autodiscovery_with_keys_that_do_not_have_alg_property()
@@ -412,6 +408,23 @@ class OidcTest extends TestCase
$this->assertEquals('xXBennyTheGeezXx', $user->external_auth_id);
}
+ public function test_auth_uses_mulitple_display_name_claims_if_configured()
+ {
+ config()->set(['oidc.display_name_claims' => 'first_name|last_name']);
+
+ $this->runLogin([
+ 'email' => 'benny@example.com',
+ 'sub' => 'benny1010101',
+ 'first_name' => 'Benny',
+ 'last_name' => 'Jenkins'
+ ]);
+
+ $this->assertDatabaseHas('users', [
+ 'name' => 'Benny Jenkins',
+ 'email' => 'benny@example.com',
+ ]);
+ }
+
public function test_login_group_sync()
{
config()->set([
diff --git a/tests/Auth/RegistrationTest.php b/tests/Auth/RegistrationTest.php
index bc190afd8..ff1a9d66b 100644
--- a/tests/Auth/RegistrationTest.php
+++ b/tests/Auth/RegistrationTest.php
@@ -2,7 +2,7 @@
namespace Tests\Auth;
-use BookStack\Notifications\ConfirmEmail;
+use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Users\Models\Role;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\DB;
@@ -28,7 +28,7 @@ class RegistrationTest extends TestCase
// Ensure notification sent
/** @var User $dbUser */
$dbUser = User::query()->where('email', '=', $user->email)->first();
- Notification::assertSentTo($dbUser, ConfirmEmail::class);
+ Notification::assertSentTo($dbUser, ConfirmEmailNotification::class);
// Test access and resend confirmation email
$resp = $this->post('/login', ['email' => $user->email, 'password' => $user->password]);
@@ -42,7 +42,7 @@ class RegistrationTest extends TestCase
// Get confirmation and confirm notification matches
$emailConfirmation = DB::table('email_confirmations')->where('user_id', '=', $dbUser->id)->first();
- Notification::assertSentTo($dbUser, ConfirmEmail::class, function ($notification, $channels) use ($emailConfirmation) {
+ Notification::assertSentTo($dbUser, ConfirmEmailNotification::class, function ($notification, $channels) use ($emailConfirmation) {
return $notification->token === $emailConfirmation->token;
});
diff --git a/tests/Auth/ResetPasswordTest.php b/tests/Auth/ResetPasswordTest.php
index b97a2f2d3..e60ac5643 100644
--- a/tests/Auth/ResetPasswordTest.php
+++ b/tests/Auth/ResetPasswordTest.php
@@ -2,7 +2,7 @@
namespace Tests\Auth;
-use BookStack\Notifications\ResetPassword;
+use BookStack\Access\Notifications\ResetPasswordNotification;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
@@ -34,8 +34,8 @@ class ResetPasswordTest extends TestCase
/** @var User $user */
$user = User::query()->where('email', '=', 'admin@admin.com')->first();
- Notification::assertSentTo($user, ResetPassword::class);
- $n = Notification::sent($user, ResetPassword::class);
+ Notification::assertSentTo($user, ResetPasswordNotification::class);
+ $n = Notification::sent($user, ResetPasswordNotification::class);
$this->get('/password/reset/' . $n->first()->token)
->assertOk()
@@ -95,7 +95,7 @@ class ResetPasswordTest extends TestCase
$resp = $this->followingRedirects()->post('/password/email', [
'email' => $editor->email,
]);
- Notification::assertTimesSent(1, ResetPassword::class);
+ Notification::assertTimesSent(1, ResetPasswordNotification::class);
$resp->assertSee('A password reset link will be sent to ' . $editor->email . ' if that email address is found in the system.');
}
}
diff --git a/tests/Auth/UserInviteTest.php b/tests/Auth/UserInviteTest.php
index 8d6143877..a9dee0007 100644
--- a/tests/Auth/UserInviteTest.php
+++ b/tests/Auth/UserInviteTest.php
@@ -2,8 +2,8 @@
namespace Tests\Auth;
+use BookStack\Access\Notifications\UserInviteNotification;
use BookStack\Access\UserInviteService;
-use BookStack\Notifications\UserInvite;
use BookStack\Users\Models\User;
use Carbon\Carbon;
use Illuminate\Notifications\Messages\MailMessage;
@@ -29,7 +29,7 @@ class UserInviteTest extends TestCase
$newUser = User::query()->where('email', '=', $email)->orderBy('id', 'desc')->first();
- Notification::assertSentTo($newUser, UserInvite::class);
+ Notification::assertSentTo($newUser, UserInviteNotification::class);
$this->assertDatabaseHas('user_invites', [
'user_id' => $newUser->id,
]);
@@ -50,7 +50,7 @@ class UserInviteTest extends TestCase
$resp->assertRedirect('/settings/users');
$newUser = User::query()->where('email', '=', $email)->orderBy('id', 'desc')->first();
- Notification::assertSentTo($newUser, UserInvite::class, function ($notification, $channels, $notifiable) {
+ Notification::assertSentTo($newUser, UserInviteNotification::class, function ($notification, $channels, $notifiable) {
/** @var MailMessage $mail */
$mail = $notification->toMail($notifiable);
diff --git a/tests/Commands/CleanupImagesCommandTest.php b/tests/Commands/CleanupImagesCommandTest.php
index a1a5ab985..36fd51e96 100644
--- a/tests/Commands/CleanupImagesCommandTest.php
+++ b/tests/Commands/CleanupImagesCommandTest.php
@@ -14,7 +14,7 @@ class CleanupImagesCommandTest extends TestCase
$this->artisan('bookstack:cleanup-images -v')
->expectsOutput('Dry run, no images have been deleted')
- ->expectsOutput('1 images found that would have been deleted')
+ ->expectsOutput('1 image(s) found that would have been deleted')
->expectsOutputToContain($image->path)
->assertExitCode(0);
@@ -29,7 +29,7 @@ class CleanupImagesCommandTest extends TestCase
$this->artisan('bookstack:cleanup-images --force')
->expectsOutputToContain('This operation is destructive and is not guaranteed to be fully accurate')
->expectsConfirmation('Are you sure you want to proceed?', 'yes')
- ->expectsOutput('1 images deleted')
+ ->expectsOutput('1 image(s) deleted')
->assertExitCode(0);
$this->assertDatabaseMissing('images', ['id' => $image->id]);
@@ -46,4 +46,17 @@ class CleanupImagesCommandTest extends TestCase
$this->assertDatabaseHas('images', ['id' => $image->id]);
}
+
+ public function test_command_force_no_interaction_run()
+ {
+ $page = $this->entities->page();
+ $image = Image::factory()->create(['uploaded_to' => $page->id]);
+
+ $this->artisan('bookstack:cleanup-images --force --no-interaction')
+ ->expectsOutputToContain('This operation is destructive and is not guaranteed to be fully accurate')
+ ->expectsOutput('1 image(s) deleted')
+ ->assertExitCode(0);
+
+ $this->assertDatabaseMissing('images', ['id' => $image->id]);
+ }
}
diff --git a/tests/Commands/RefreshAvatarCommandTest.php b/tests/Commands/RefreshAvatarCommandTest.php
new file mode 100644
index 000000000..6126f21a8
--- /dev/null
+++ b/tests/Commands/RefreshAvatarCommandTest.php
@@ -0,0 +1,246 @@
+set([
+ 'services.disable_services' => false,
+ 'services.avatar_url' => 'https://avatars.example.com?a=b',
+ ]);
+ }
+
+ public function test_command_errors_if_avatar_fetch_disabled()
+ {
+ config()->set(['services.avatar_url' => false]);
+
+ $this->artisan('bookstack:refresh-avatar')
+ ->expectsOutputToContain("Avatar fetching is disabled on this instance")
+ ->assertExitCode(1);
+ }
+
+ public function test_command_requires_email_or_id_option()
+ {
+ $this->artisan('bookstack:refresh-avatar')
+ ->expectsOutputToContain("Either a --id=
or --email= option must be provided")
+ ->assertExitCode(1);
+ }
+
+ public function test_command_runs_with_provided_email()
+ {
+ $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]);
+
+ $user = $this->users->viewer();
+ $this->assertFalse($user->avatar()->exists());
+
+ $this->artisan("bookstack:refresh-avatar --email={$user->email} -f")
+ ->expectsQuestion('Are you sure you want to proceed?', true)
+ ->expectsOutput("[ID: {$user->id}] {$user->email} - Updated")
+ ->expectsOutputToContain('This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from avatars.example.com')
+ ->assertExitCode(0);
+
+ $this->assertEquals('https://avatars.example.com?a=b', $requests->latestRequest()->getUri());
+
+ $user->refresh();
+ $this->assertTrue($user->avatar()->exists());
+ }
+
+ public function test_command_runs_with_provided_id()
+ {
+ $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]);
+
+ $user = $this->users->viewer();
+ $this->assertFalse($user->avatar()->exists());
+
+ $this->artisan("bookstack:refresh-avatar --id={$user->id} -f")
+ ->expectsQuestion('Are you sure you want to proceed?', true)
+ ->expectsOutput("[ID: {$user->id}] {$user->email} - Updated")
+ ->assertExitCode(0);
+
+ $this->assertEquals('https://avatars.example.com?a=b', $requests->latestRequest()->getUri());
+
+ $user->refresh();
+ $this->assertTrue($user->avatar()->exists());
+ }
+
+ public function test_command_runs_with_provided_id_error_upstream()
+ {
+ $requests = $this->mockHttpClient([new Response(404)]);
+
+ $user = $this->users->viewer();
+ $this->assertFalse($user->avatar()->exists());
+
+ $this->artisan("bookstack:refresh-avatar --id={$user->id} -f")
+ ->expectsQuestion('Are you sure you want to proceed?', true)
+ ->expectsOutput("[ID: {$user->id}] {$user->email} - Not updated")
+ ->assertExitCode(1);
+
+ $this->assertEquals(1, $requests->requestCount());
+ $this->assertFalse($user->avatar()->exists());
+ }
+
+ public function test_saying_no_to_confirmation_does_not_refresh_avatar()
+ {
+ $user = $this->users->viewer();
+
+ $this->assertFalse($user->avatar()->exists());
+ $this->artisan("bookstack:refresh-avatar --id={$user->id} -f")
+ ->expectsQuestion('Are you sure you want to proceed?', false)
+ ->assertExitCode(0);
+ $this->assertFalse($user->avatar()->exists());
+ }
+
+ public function test_giving_non_existing_user_shows_error_message()
+ {
+ $this->artisan('bookstack:refresh-avatar --email=donkeys@example.com')
+ ->expectsOutput('A user where email=donkeys@example.com could not be found.')
+ ->assertExitCode(1);
+ }
+
+ public function test_command_runs_all_users_without_avatars_dry_run()
+ {
+ $users = User::query()->where('image_id', '=', 0)->get();
+
+ $this->artisan('bookstack:refresh-avatar --users-without-avatars')
+ ->expectsOutput(count($users) . ' user(s) found to update avatars for.')
+ ->expectsOutput("[ID: {$users[0]->id}] {$users[0]->email} - Not updated")
+ ->expectsOutput('Dry run, no avatars were updated.')
+ ->assertExitCode(0);
+ }
+
+ public function test_command_runs_all_users_without_avatars_with_none_to_update()
+ {
+ $requests = $this->mockHttpClient();
+ $image = Image::factory()->create();
+ User::query()->update(['image_id' => $image->id]);
+
+ $this->artisan('bookstack:refresh-avatar --users-without-avatars -f')
+ ->expectsOutput('0 user(s) found to update avatars for.')
+ ->assertExitCode(0);
+
+ $this->assertEquals(0, $requests->requestCount());
+ }
+
+ public function test_command_runs_all_users_without_avatars()
+ {
+ /** @var Collection|User[] $users */
+ $users = User::query()->where('image_id', '=', 0)->get();
+
+ $pendingCommand = $this->artisan('bookstack:refresh-avatar --users-without-avatars -f');
+ $pendingCommand
+ ->expectsOutput($users->count() . ' user(s) found to update avatars for.')
+ ->expectsQuestion('Are you sure you want to proceed?', true);
+
+ $responses = [];
+ foreach ($users as $user) {
+ $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Updated");
+ $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData());
+ }
+ $requests = $this->mockHttpClient($responses);
+
+ $pendingCommand->assertExitCode(0);
+ $pendingCommand->run();
+
+ $this->assertEquals(0, User::query()->where('image_id', '=', 0)->count());
+ $this->assertEquals($users->count(), $requests->requestCount());
+ }
+
+ public function test_saying_no_to_confirmation_all_users_without_avatars()
+ {
+ $requests = $this->mockHttpClient();
+
+ $this->artisan('bookstack:refresh-avatar --users-without-avatars -f')
+ ->expectsQuestion('Are you sure you want to proceed?', false)
+ ->assertExitCode(0);
+
+ $this->assertEquals(0, $requests->requestCount());
+ }
+
+ public function test_command_runs_all_users_dry_run()
+ {
+ $users = User::query()->where('image_id', '=', 0)->get();
+
+ $this->artisan('bookstack:refresh-avatar --all')
+ ->expectsOutput(count($users) . ' user(s) found to update avatars for.')
+ ->expectsOutput("[ID: {$users[0]->id}] {$users[0]->email} - Not updated")
+ ->expectsOutput('Dry run, no avatars were updated.')
+ ->assertExitCode(0);
+ }
+
+ public function test_command_runs_update_all_users_avatar()
+ {
+ /** @var Collection|User[] $users */
+ $users = User::query()->get();
+
+ $pendingCommand = $this->artisan('bookstack:refresh-avatar --all -f');
+ $pendingCommand
+ ->expectsOutput($users->count() . ' user(s) found to update avatars for.')
+ ->expectsQuestion('Are you sure you want to proceed?', true);
+
+ $responses = [];
+ foreach ($users as $user) {
+ $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Updated");
+ $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData());
+ }
+ $requests = $this->mockHttpClient($responses);
+
+ $pendingCommand->assertExitCode(0);
+ $pendingCommand->run();
+
+ $this->assertEquals(0, User::query()->where('image_id', '=', 0)->count());
+ $this->assertEquals($users->count(), $requests->requestCount());
+ }
+
+ public function test_command_runs_update_all_users_avatar_errors()
+ {
+ /** @var Collection|User[] $users */
+ $users = array_values(User::query()->get()->all());
+
+ $pendingCommand = $this->artisan('bookstack:refresh-avatar --all -f');
+ $pendingCommand
+ ->expectsOutput(count($users) . ' user(s) found to update avatars for.')
+ ->expectsQuestion('Are you sure you want to proceed?', true);
+
+ $responses = [];
+ foreach ($users as $index => $user) {
+ if ($index === 0) {
+ $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Not updated");
+ $responses[] = new Response(404);
+ continue;
+ }
+
+ $pendingCommand->expectsOutput("[ID: {$user->id}] {$user->email} - Updated");
+ $responses[] = new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData());
+ }
+
+ $requests = $this->mockHttpClient($responses);
+
+ $pendingCommand->assertExitCode(1);
+ $pendingCommand->run();
+
+ $userWithAvatars = User::query()->where('image_id', '!=', 0)->count();
+ $this->assertEquals(count($users) - 1, $userWithAvatars);
+ $this->assertEquals(count($users), $requests->requestCount());
+ }
+
+ public function test_saying_no_to_confirmation_update_all_users_avatar()
+ {
+ $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]);
+
+ $this->artisan('bookstack:refresh-avatar --all -f')
+ ->expectsQuestion('Are you sure you want to proceed?', false)
+ ->assertExitCode(0);
+
+ $this->assertEquals(0, $requests->requestCount());
+ }
+}
diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php
index 0a71bb6ef..23fc68197 100644
--- a/tests/Entity/CommentTest.php
+++ b/tests/Entity/CommentTest.php
@@ -152,4 +152,16 @@ class CommentTest extends TestCase
$respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
$respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
}
+
+ public function test_comment_creator_name_truncated()
+ {
+ [$longNamedUser] = $this->users->newUserWithRole(['name' => 'Wolfeschlegelsteinhausenbergerdorff'], ['comment-create-all', 'page-view-all']);
+ $page = $this->entities->page();
+
+ $comment = Comment::factory()->make();
+ $this->actingAs($longNamedUser)->postJson("/comment/$page->id", $comment->getAttributes());
+
+ $pageResp = $this->asAdmin()->get($page->getUrl());
+ $pageResp->assertSee('Wolfeschlegels…');
+ }
}
diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php
index 97d5a6664..a272dc38b 100644
--- a/tests/Entity/PageRevisionTest.php
+++ b/tests/Entity/PageRevisionTest.php
@@ -136,7 +136,7 @@ class PageRevisionTest extends TestCase
$page = $this->entities->page();
$this->createRevisions($page, 2);
- $pageView = $this->get($page->getUrl());
+ $pageView = $this->asViewer()->get($page->getUrl());
$pageView->assertSee('Revision #' . $page->revision_count);
}
diff --git a/tests/Entity/SearchOptionsTest.php b/tests/Entity/SearchOptionsTest.php
index cac9c67f1..8bc9d02e4 100644
--- a/tests/Entity/SearchOptionsTest.php
+++ b/tests/Entity/SearchOptionsTest.php
@@ -3,6 +3,7 @@
namespace Tests\Entity;
use BookStack\Search\SearchOptions;
+use Illuminate\Http\Request;
use Tests\TestCase;
class SearchOptionsTest extends TestCase
@@ -17,6 +18,13 @@ class SearchOptionsTest extends TestCase
$this->assertEquals(['is_tree' => ''], $options->filters);
}
+ public function test_from_string_properly_parses_escaped_quotes()
+ {
+ $options = SearchOptions::fromString('"\"cat\"" surprise "\"\"" "\"donkey" "\""');
+
+ $this->assertEquals(['"cat"', '""', '"donkey', '"'], $options->exacts);
+ }
+
public function test_to_string_includes_all_items_in_the_correct_format()
{
$expected = 'cat "dog" [tag=good] {is_tree}';
@@ -32,6 +40,15 @@ class SearchOptionsTest extends TestCase
}
}
+ public function test_to_string_escapes_quotes_as_expected()
+ {
+ $options = new SearchOptions();
+ $options->exacts = ['"cat"', '""', '"donkey', '"'];
+
+ $output = $options->toString();
+ $this->assertEquals('"\"cat\"" "\"\"" "\"donkey" "\""', $output);
+ }
+
public function test_correct_filter_values_are_set_from_string()
{
$opts = SearchOptions::fromString('{is_tree} {name:dan} {cat:happy}');
@@ -42,4 +59,22 @@ class SearchOptionsTest extends TestCase
'cat' => 'happy',
], $opts->filters);
}
+ public function test_it_cannot_parse_out_empty_exacts()
+ {
+ $options = SearchOptions::fromString('"" test ""');
+
+ $this->assertEmpty($options->exacts);
+ $this->assertCount(1, $options->searches);
+ }
+
+ public function test_from_request_properly_parses_exacts_from_search_terms()
+ {
+ $request = new Request([
+ 'search' => 'biscuits "cheese" "" "baked beans"'
+ ]);
+
+ $options = SearchOptions::fromRequest($request);
+ $this->assertEquals(["biscuits"], $options->searches);
+ $this->assertEquals(['"cheese"', '""', '"baked', 'beans"'], $options->exacts);
+ }
}
diff --git a/tests/FavouriteTest.php b/tests/FavouriteTest.php
index 0e30cbd58..48048e284 100644
--- a/tests/FavouriteTest.php
+++ b/tests/FavouriteTest.php
@@ -14,10 +14,10 @@ class FavouriteTest extends TestCase
$resp = $this->actingAs($editor)->get($page->getUrl());
$this->withHtml($resp)->assertElementContains('button', 'Favourite');
- $this->withHtml($resp)->assertElementExists('form[method="POST"][action$="/favourites/add"]');
+ $this->withHtml($resp)->assertElementExists('form[method="POST"][action$="/favourites/add"] input[name="type"][value="page"]');
$resp = $this->post('/favourites/add', [
- 'type' => get_class($page),
+ 'type' => $page->getMorphClass(),
'id' => $page->id,
]);
$resp->assertRedirect($page->getUrl());
@@ -45,7 +45,7 @@ class FavouriteTest extends TestCase
$this->withHtml($resp)->assertElementExists('form[method="POST"][action$="/favourites/remove"]');
$resp = $this->post('/favourites/remove', [
- 'type' => get_class($page),
+ 'type' => $page->getMorphClass(),
'id' => $page->id,
]);
$resp->assertRedirect($page->getUrl());
@@ -67,7 +67,7 @@ class FavouriteTest extends TestCase
$this->actingAs($user)->get($book->getUrl());
$resp = $this->post('/favourites/add', [
- 'type' => get_class($book),
+ 'type' => $book->getMorphClass(),
'id' => $book->id,
]);
$resp->assertRedirect($book->getUrl());
diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php
index ddc854290..3cb8c44d3 100644
--- a/tests/Helpers/EntityProvider.php
+++ b/tests/Helpers/EntityProvider.php
@@ -11,6 +11,7 @@ use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\TrashCan;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
@@ -197,6 +198,16 @@ class EntityProvider
return $draftPage;
}
+ /**
+ * Fully destroy the given entity from the system, bypassing the recycle bin
+ * stage. Still runs through main app deletion logic.
+ */
+ public function destroy(Entity $entity)
+ {
+ $trash = app()->make(TrashCan::class);
+ $trash->destroyEntity($entity);
+ }
+
/**
* @param Entity|Entity[] $entities
*/
diff --git a/tests/Helpers/UserRoleProvider.php b/tests/Helpers/UserRoleProvider.php
index b86e90394..fe19cad4a 100644
--- a/tests/Helpers/UserRoleProvider.php
+++ b/tests/Helpers/UserRoleProvider.php
@@ -50,6 +50,14 @@ class UserRoleProvider
return $user;
}
+ /**
+ * Get the system "guest" user.
+ */
+ public function guest(): User
+ {
+ return User::getGuest();
+ }
+
/**
* Create a new fresh user without any relations.
*/
diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php
index a66227ff2..6b6856184 100644
--- a/tests/LanguageTest.php
+++ b/tests/LanguageTest.php
@@ -3,6 +3,7 @@
namespace Tests;
use BookStack\Activity\ActivityType;
+use BookStack\Translation\LocaleManager;
class LanguageTest extends TestCase
{
@@ -17,12 +18,12 @@ class LanguageTest extends TestCase
$this->langs = array_diff(scandir(lang_path('')), ['..', '.']);
}
- public function test_locales_config_key_set_properly()
+ public function test_locales_list_set_properly()
{
- $configLocales = config('app.locales');
- sort($configLocales);
+ $appLocales = $this->app->make(LocaleManager::class)->getAllAppLocales();
+ sort($appLocales);
sort($this->langs);
- $this->assertEquals(implode(':', $configLocales), implode(':', $this->langs), 'app.locales configuration variable does not match those found in lang files');
+ $this->assertEquals(implode(':', $this->langs), implode(':', $appLocales), 'app.locales configuration variable does not match those found in lang files');
}
// Not part of standard phpunit test runs since we sometimes expect non-added langs.
@@ -75,13 +76,13 @@ class LanguageTest extends TestCase
}
}
- public function test_rtl_config_set_if_lang_is_rtl()
+ public function test_views_use_rtl_if_rtl_language_is_set()
{
- $this->asEditor();
- $this->assertFalse(config('app.rtl'), 'App RTL config should be false by default');
+ $this->asEditor()->withHtml($this->get('/'))->assertElementExists('html[dir="ltr"]');
+
setting()->putUser($this->users->editor(), 'language', 'ar');
- $this->get('/');
- $this->assertTrue(config('app.rtl'), 'App RTL config should have been set to true by middleware');
+
+ $this->withHtml($this->get('/'))->assertElementExists('html[dir="rtl"]');
}
public function test_unknown_lang_does_not_break_app()
diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php
index 6f0e2f1d3..875b279a8 100644
--- a/tests/PublicActionTest.php
+++ b/tests/PublicActionTest.php
@@ -103,7 +103,7 @@ class PublicActionTest extends TestCase
$resp = $this->post($chapter->getUrl('/create-guest-page'), ['name' => 'My guest page']);
$resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit'));
- $user = User::getDefault();
+ $user = $this->users->guest();
$this->assertDatabaseHas('pages', [
'name' => 'My guest page',
'chapter_id' => $chapter->id,
@@ -197,7 +197,7 @@ class PublicActionTest extends TestCase
public function test_public_view_can_take_on_other_roles()
{
$this->setSettings(['app-public' => 'true']);
- $newRole = $this->users->attachNewRole(User::getDefault(), []);
+ $newRole = $this->users->attachNewRole($this->users->guest(), []);
$page = $this->entities->page();
$this->permissions->disableEntityInheritedPermissions($page);
$this->permissions->addEntityPermission($page, ['view', 'update'], $newRole);
@@ -207,4 +207,16 @@ class PublicActionTest extends TestCase
$this->withHtml($resp)->assertLinkExists($page->getUrl('/edit'));
}
+
+ public function test_public_user_cannot_view_or_update_their_profile()
+ {
+ $this->setSettings(['app-public' => 'true']);
+ $guest = $this->users->guest();
+
+ $resp = $this->get($guest->getEditUrl());
+ $this->assertPermissionError($resp);
+
+ $resp = $this->put($guest->getEditUrl(), ['name' => 'My new guest name']);
+ $this->assertPermissionError($resp);
+ }
}
diff --git a/tests/Settings/TestEmailTest.php b/tests/Settings/TestEmailTest.php
index 322f90107..e96024e7b 100644
--- a/tests/Settings/TestEmailTest.php
+++ b/tests/Settings/TestEmailTest.php
@@ -2,7 +2,7 @@
namespace Tests\Settings;
-use BookStack\Notifications\TestEmail;
+use BookStack\Settings\TestEmailNotification;
use Illuminate\Contracts\Notifications\Dispatcher;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
@@ -26,7 +26,7 @@ class TestEmailTest extends TestCase
$sendReq->assertRedirect('/settings/maintenance#image-cleanup');
$this->assertSessionHas('success', 'Email sent to ' . $admin->email);
- Notification::assertSentTo($admin, TestEmail::class);
+ Notification::assertSentTo($admin, TestEmailNotification::class);
}
public function test_send_test_email_failure_displays_error_notification()
@@ -57,6 +57,6 @@ class TestEmailTest extends TestCase
$this->permissions->grantUserRolePermissions($user, ['settings-manage']);
$sendReq = $this->actingAs($user)->post('/settings/maintenance/send-test-email');
- Notification::assertSentTo($user, TestEmail::class);
+ Notification::assertSentTo($user, TestEmailNotification::class);
}
}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 322ab0370..c59f843e9 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -3,12 +3,10 @@
namespace Tests;
use BookStack\Entities\Models\Entity;
+use BookStack\Http\HttpClientHistory;
+use BookStack\Http\HttpRequestService;
use BookStack\Settings\SettingService;
-use BookStack\Uploads\HttpFetcher;
-use GuzzleHttp\Client;
-use GuzzleHttp\Handler\MockHandler;
-use GuzzleHttp\HandlerStack;
-use GuzzleHttp\Middleware;
+use BookStack\Users\Models\User;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
@@ -17,10 +15,8 @@ use Illuminate\Support\Env;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Testing\Assert as PHPUnit;
-use Mockery;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
-use Psr\Http\Client\ClientInterface;
use Ssddanbrown\AssertHtml\TestsHtml;
use Tests\Helpers\EntityProvider;
use Tests\Helpers\FileProvider;
@@ -109,33 +105,11 @@ abstract class TestCase extends BaseTestCase
}
/**
- * Mock the HttpFetcher service and return the given data on fetch.
+ * Mock the http client used in BookStack http calls.
*/
- protected function mockHttpFetch($returnData, int $times = 1)
+ protected function mockHttpClient(array $responses = []): HttpClientHistory
{
- $mockHttp = Mockery::mock(HttpFetcher::class);
- $this->app[HttpFetcher::class] = $mockHttp;
- $mockHttp->shouldReceive('fetch')
- ->times($times)
- ->andReturn($returnData);
- }
-
- /**
- * Mock the http client used in BookStack.
- * Returns a reference to the container which holds all history of http transactions.
- *
- * @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
- */
- protected function &mockHttpClient(array $responses = []): array
- {
- $container = [];
- $history = Middleware::history($container);
- $mock = new MockHandler($responses);
- $handlerStack = new HandlerStack($mock);
- $handlerStack->push($history);
- $this->app[ClientInterface::class] = new Client(['handler' => $handlerStack]);
-
- return $container;
+ return $this->app->make(HttpRequestService::class)->mockClient($responses);
}
/**
diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php
index 6976f2384..f0266cd0c 100644
--- a/tests/ThemeTest.php
+++ b/tests/ThemeTest.php
@@ -8,17 +8,15 @@ use BookStack\Activity\Models\Webhook;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\PageContent;
+use BookStack\Exceptions\ThemeException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Console\Command;
-use Illuminate\Http\Client\Request as HttpClientRequest;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
-use Illuminate\Support\Facades\Http;
-use League\CommonMark\ConfigurableEnvironmentInterface;
use League\CommonMark\Environment\Environment;
class ThemeTest extends TestCase
@@ -54,6 +52,19 @@ class ThemeTest extends TestCase
});
}
+ public function test_theme_functions_loads_errors_are_caught_and_logged()
+ {
+ $this->usingThemeFolder(function ($themeFolder) {
+ $functionsFile = theme_path('functions.php');
+ file_put_contents($functionsFile, "expectException(ThemeException::class);
+ $this->expectExceptionMessageMatches('/Failed loading theme functions file at ".*?" with error: Class "BookStack\\\\Biscuits" not found/');
+
+ $this->runWithEnv('APP_THEME', $themeFolder, fn() => null);
+ });
+ }
+
public function test_event_commonmark_environment_configure()
{
$callbackCalled = false;
@@ -177,9 +188,7 @@ class ThemeTest extends TestCase
};
Theme::listen(ThemeEvents::WEBHOOK_CALL_BEFORE, $callback);
- Http::fake([
- '*' => Http::response('', 200),
- ]);
+ $responses = $this->mockHttpClient([new \GuzzleHttp\Psr7\Response(200, [], '')]);
$webhook = new Webhook(['name' => 'Test webhook', 'endpoint' => 'https://example.com']);
$webhook->save();
@@ -193,9 +202,10 @@ class ThemeTest extends TestCase
$this->assertEquals($webhook->id, $args[1]->id);
$this->assertEquals($detail->id, $args[2]->id);
- Http::assertSent(function (HttpClientRequest $request) {
- return $request->isJson() && $request->data()['test'] === 'hello!';
- });
+ $this->assertEquals(1, $responses->requestCount());
+ $request = $responses->latestRequest();
+ $reqData = json_decode($request->getBody(), true);
+ $this->assertEquals('hello!', $reqData['test']);
}
public function test_event_activity_logged()
diff --git a/tests/Unit/SsrUrlValidatorTest.php b/tests/Unit/SsrUrlValidatorTest.php
new file mode 100644
index 000000000..8fb538916
--- /dev/null
+++ b/tests/Unit/SsrUrlValidatorTest.php
@@ -0,0 +1,62 @@
+ '', 'url' => '', 'result' => false],
+ ['config' => '', 'url' => 'https://example.com', 'result' => false],
+ ['config' => ' ', 'url' => 'https://example.com', 'result' => false],
+ ['config' => '*', 'url' => '', 'result' => false],
+ ['config' => '*', 'url' => 'https://example.com', 'result' => true],
+ ['config' => 'https://*', 'url' => 'https://example.com', 'result' => true],
+ ['config' => 'http://*', 'url' => 'https://example.com', 'result' => false],
+ ['config' => 'https://*example.com', 'url' => 'https://example.com', 'result' => true],
+ ['config' => 'https://*ample.com', 'url' => 'https://example.com', 'result' => true],
+ ['config' => 'https://*.example.com', 'url' => 'https://example.com', 'result' => false],
+ ['config' => 'https://*.example.com', 'url' => 'https://test.example.com', 'result' => true],
+ ['config' => '*//example.com', 'url' => 'https://example.com', 'result' => true],
+ ['config' => '*//example.com', 'url' => 'http://example.com', 'result' => true],
+ ['config' => '*//example.co', 'url' => 'http://example.co.uk', 'result' => false],
+ ['config' => '*//example.co/bookstack', 'url' => 'https://example.co/bookstack/a/path', 'result' => true],
+ ['config' => '*//example.co*', 'url' => 'https://example.co.uk/bookstack/a/path', 'result' => true],
+ ['config' => 'https://example.com', 'url' => 'https://example.com/a/b/c?test=cat', 'result' => true],
+ ['config' => 'https://example.com', 'url' => 'https://example.co.uk', 'result' => false],
+
+ // Escapes
+ ['config' => 'https://(.*?).com', 'url' => 'https://example.com', 'result' => false],
+ ['config' => 'https://example.com', 'url' => 'https://example.co.uk#https://example.com', 'result' => false],
+
+ // Multi values
+ ['config' => '*//example.org *//example.com', 'url' => 'https://example.com', 'result' => true],
+ ['config' => '*//example.org *//example.com', 'url' => 'https://example.com/a/b/c?test=cat#hello', 'result' => true],
+ ['config' => '*.example.org *.example.com', 'url' => 'https://example.co.uk', 'result' => false],
+ ['config' => ' *.example.org *.example.com ', 'url' => 'https://example.co.uk', 'result' => false],
+ ['config' => '* *.example.com', 'url' => 'https://example.co.uk', 'result' => true],
+ ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.co.uk', 'result' => true],
+ ['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.net', 'result' => false],
+ ];
+
+ foreach ($testMap as $test) {
+ $result = (new SsrUrlValidator($test['config']))->allowed($test['url']);
+ $this->assertEquals($test['result'], $result, "Failed asserting url '{$test['url']}' with config '{$test['config']}' results " . ($test['result'] ? 'true' : 'false'));
+ }
+ }
+
+ public function test_enssure_allowed()
+ {
+ $result = (new SsrUrlValidator('https://example.com'))->ensureAllowed('https://example.com');
+ $this->assertNull($result);
+
+ $this->expectException(HttpFetchException::class);
+ (new SsrUrlValidator('https://example.com'))->ensureAllowed('https://test.example.com');
+ }
+}
diff --git a/tests/Uploads/AvatarTest.php b/tests/Uploads/AvatarTest.php
index 363c1fa95..f5b49a9fc 100644
--- a/tests/Uploads/AvatarTest.php
+++ b/tests/Uploads/AvatarTest.php
@@ -3,9 +3,11 @@
namespace Tests\Uploads;
use BookStack\Exceptions\HttpFetchException;
-use BookStack\Uploads\HttpFetcher;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\Psr7\Response;
use Tests\TestCase;
class AvatarTest extends TestCase
@@ -22,27 +24,16 @@ class AvatarTest extends TestCase
return User::query()->where('email', '=', $user->email)->first();
}
- protected function assertImageFetchFrom(string $url)
- {
- $http = $this->mock(HttpFetcher::class);
-
- $http->shouldReceive('fetch')
- ->once()->with($url)
- ->andReturn($this->files->pngImageData());
- }
-
- protected function deleteUserImage(User $user)
+ protected function deleteUserImage(User $user): void
{
$this->files->deleteAtRelativePath($user->avatar->path);
}
public function test_gravatar_fetched_on_user_create()
{
- config()->set([
- 'services.disable_services' => false,
- ]);
+ $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]);
+ config()->set(['services.disable_services' => false]);
$user = User::factory()->make();
- $this->assertImageFetchFrom('https://www.gravatar.com/avatar/' . md5(strtolower($user->email)) . '?s=500&d=identicon');
$user = $this->createUserRequest($user);
$this->assertDatabaseHas('images', [
@@ -50,6 +41,9 @@ class AvatarTest extends TestCase
'created_by' => $user->id,
]);
$this->deleteUserImage($user);
+
+ $expectedUri = 'https://www.gravatar.com/avatar/' . md5(strtolower($user->email)) . '?s=500&d=identicon';
+ $this->assertEquals($expectedUri, $requests->latestRequest()->getUri());
}
public function test_custom_url_used_if_set()
@@ -61,24 +55,22 @@ class AvatarTest extends TestCase
$user = User::factory()->make();
$url = 'https://example.com/' . urlencode(strtolower($user->email)) . '/' . md5(strtolower($user->email)) . '/500';
- $this->assertImageFetchFrom($url);
+ $requests = $this->mockHttpClient([new Response(200, ['Content-Type' => 'image/png'], $this->files->pngImageData())]);
$user = $this->createUserRequest($user);
+ $this->assertEquals($url, $requests->latestRequest()->getUri());
$this->deleteUserImage($user);
}
public function test_avatar_not_fetched_if_no_custom_url_and_services_disabled()
{
- config()->set([
- 'services.disable_services' => true,
- ]);
-
+ config()->set(['services.disable_services' => true]);
$user = User::factory()->make();
-
- $http = $this->mock(HttpFetcher::class);
- $http->shouldNotReceive('fetch');
+ $requests = $this->mockHttpClient([new Response()]);
$this->createUserRequest($user);
+
+ $this->assertEquals(0, $requests->requestCount());
}
public function test_avatar_not_fetched_if_avatar_url_option_set_to_false()
@@ -89,21 +81,18 @@ class AvatarTest extends TestCase
]);
$user = User::factory()->make();
-
- $http = $this->mock(HttpFetcher::class);
- $http->shouldNotReceive('fetch');
+ $requests = $this->mockHttpClient([new Response()]);
$this->createUserRequest($user);
+
+ $this->assertEquals(0, $requests->requestCount());
}
public function test_no_failure_but_error_logged_on_failed_avatar_fetch()
{
- config()->set([
- 'services.disable_services' => false,
- ]);
+ config()->set(['services.disable_services' => false]);
- $http = $this->mock(HttpFetcher::class);
- $http->shouldReceive('fetch')->andThrow(new HttpFetchException());
+ $this->mockHttpClient([new ConnectException('Failed to connect', new Request('GET', ''))]);
$logger = $this->withTestLogger();
@@ -122,17 +111,16 @@ class AvatarTest extends TestCase
$user = User::factory()->make();
$avatar = app()->make(UserAvatars::class);
- $url = 'http_malformed_url/' . urlencode(strtolower($user->email)) . '/' . md5(strtolower($user->email)) . '/500';
$logger = $this->withTestLogger();
+ $this->mockHttpClient([new ConnectException('Could not resolve host http_malformed_url', new Request('GET', ''))]);
$avatar->fetchAndAssignToUser($user);
+ $url = 'http_malformed_url/' . urlencode(strtolower($user->email)) . '/' . md5(strtolower($user->email)) . '/500';
$this->assertTrue($logger->hasError('Failed to save user avatar image'));
$exception = $logger->getRecords()[0]['context']['exception'];
- $this->assertEquals(new HttpFetchException(
- 'Cannot get image from ' . $url,
- 6,
- (new HttpFetchException('Could not resolve host: http_malformed_url', 6))
- ), $exception);
+ $this->assertInstanceOf(HttpFetchException::class, $exception);
+ $this->assertEquals('Cannot get image from ' . $url, $exception->getMessage());
+ $this->assertEquals('Could not resolve host http_malformed_url', $exception->getPrevious()->getMessage());
}
}
diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php
index df60bede6..93d35f5d0 100644
--- a/tests/User/UserManagementTest.php
+++ b/tests/User/UserManagementTest.php
@@ -191,7 +191,7 @@ class UserManagementTest extends TestCase
public function test_guest_profile_shows_limited_form()
{
- $guest = User::getDefault();
+ $guest = $this->users->guest();
$resp = $this->asAdmin()->get('/settings/users/' . $guest->id);
$resp->assertSee('Guest');
$this->withHtml($resp)->assertElementNotExists('#password');
@@ -199,7 +199,7 @@ class UserManagementTest extends TestCase
public function test_guest_profile_cannot_be_deleted()
{
- $guestUser = User::getDefault();
+ $guestUser = $this->users->guest();
$resp = $this->asAdmin()->get('/settings/users/' . $guestUser->id . '/delete');
$resp->assertSee('Delete User');
$resp->assertSee('Guest');
@@ -215,7 +215,7 @@ class UserManagementTest extends TestCase
{
$langs = ['en', 'fr', 'hr'];
foreach ($langs as $lang) {
- config()->set('app.locale', $lang);
+ config()->set('app.default_locale', $lang);
$resp = $this->asAdmin()->get('/settings/users/create');
$this->withHtml($resp)->assertElementExists('select[name="language"] option[value="' . $lang . '"][selected]');
}
diff --git a/tests/User/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php
index e47a259a5..4a6cba7b3 100644
--- a/tests/User/UserPreferencesTest.php
+++ b/tests/User/UserPreferencesTest.php
@@ -2,10 +2,30 @@
namespace Tests\User;
+use BookStack\Activity\Tools\UserEntityWatchOptions;
+use BookStack\Activity\WatchLevels;
use Tests\TestCase;
class UserPreferencesTest extends TestCase
{
+ public function test_index_view()
+ {
+ $resp = $this->asEditor()->get('/preferences');
+ $resp->assertOk();
+ $resp->assertSee('Interface Keyboard Shortcuts');
+ $resp->assertSee('Edit Profile');
+ }
+
+ public function test_index_view_accessible_but_without_profile_and_notifications_for_guest_user()
+ {
+ $this->setSettings(['app-public' => 'true']);
+ $this->permissions->grantUserRolePermissions($this->users->guest(), ['receive-notifications']);
+ $resp = $this->get('/preferences');
+ $resp->assertOk();
+ $resp->assertSee('Interface Keyboard Shortcuts');
+ $resp->assertDontSee('Edit Profile');
+ $resp->assertDontSee('Notification');
+ }
public function test_interface_shortcuts_updating()
{
$this->asEditor();
@@ -45,6 +65,110 @@ class UserPreferencesTest extends TestCase
$this->withHtml($this->get('/'))->assertElementExists('body[component="shortcuts"]');
}
+ public function test_notification_routes_requires_notification_permission()
+ {
+ $viewer = $this->users->viewer();
+ $resp = $this->actingAs($viewer)->get('/preferences/notifications');
+ $this->assertPermissionError($resp);
+
+ $resp = $this->put('/preferences/notifications');
+ $this->assertPermissionError($resp);
+
+ $this->permissions->grantUserRolePermissions($viewer, ['receive-notifications']);
+ $resp = $this->get('/preferences/notifications');
+ $resp->assertOk();
+ $resp->assertSee('Notification Preferences');
+ }
+
+ public function test_notification_preferences_updating()
+ {
+ $editor = $this->users->editor();
+
+ // View preferences with defaults
+ $resp = $this->actingAs($editor)->get('/preferences/notifications');
+ $resp->assertSee('Notification Preferences');
+
+ $html = $this->withHtml($resp);
+ $html->assertFieldHasValue('preferences[comment-replies]', 'false');
+
+ // Update preferences
+ $resp = $this->put('/preferences/notifications', [
+ 'preferences' => ['comment-replies' => 'true'],
+ ]);
+
+ $resp->assertRedirect('/preferences/notifications');
+ $resp->assertSessionHas('success', 'Notification preferences have been updated!');
+
+ // View updates to preferences page
+ $resp = $this->get('/preferences/notifications');
+ $html = $this->withHtml($resp);
+ $html->assertFieldHasValue('preferences[comment-replies]', 'true');
+ }
+
+ public function test_notification_preferences_show_watches()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->book();
+
+ $options = new UserEntityWatchOptions($editor, $book);
+ $options->updateLevelByValue(WatchLevels::COMMENTS);
+
+ $resp = $this->actingAs($editor)->get('/preferences/notifications');
+ $resp->assertSee($book->name);
+ $resp->assertSee('All Page Updates & Comments');
+
+ $options->updateLevelByValue(WatchLevels::DEFAULT);
+
+ $resp = $this->actingAs($editor)->get('/preferences/notifications');
+ $resp->assertDontSee($book->name);
+ $resp->assertDontSee('All Page Updates & Comments');
+ }
+
+ public function test_notification_preferences_dont_error_on_deleted_items()
+ {
+ $editor = $this->users->editor();
+ $book = $this->entities->book();
+
+ $options = new UserEntityWatchOptions($editor, $book);
+ $options->updateLevelByValue(WatchLevels::COMMENTS);
+
+ $this->actingAs($editor)->delete($book->getUrl());
+ $book->refresh();
+ $this->assertNotNull($book->deleted_at);
+
+ $resp = $this->actingAs($editor)->get('/preferences/notifications');
+ $resp->assertOk();
+ $resp->assertDontSee($book->name);
+ }
+
+ public function test_notification_preferences_not_accessible_to_guest()
+ {
+ $this->setSettings(['app-public' => 'true']);
+ $guest = $this->users->guest();
+ $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']);
+
+ $resp = $this->get('/preferences/notifications');
+ $this->assertPermissionError($resp);
+
+ $resp = $this->put('/preferences/notifications', [
+ 'preferences' => ['comment-replies' => 'true'],
+ ]);
+ $this->assertPermissionError($resp);
+ }
+
+ public function test_notification_comment_options_only_exist_if_comments_active()
+ {
+ $resp = $this->asEditor()->get('/preferences/notifications');
+ $resp->assertSee('Notify upon comments');
+ $resp->assertSee('Notify upon replies');
+
+ setting()->put('app-disable-comments', true);
+
+ $resp = $this->get('/preferences/notifications');
+ $resp->assertDontSee('Notify upon comments');
+ $resp->assertDontSee('Notify upon replies');
+ }
+
public function test_update_sort_preference()
{
$editor = $this->users->editor();
@@ -131,6 +255,22 @@ class UserPreferencesTest extends TestCase
$this->withHtml($home)->assertElementExists('.dark-mode');
}
+ public function test_dark_mode_toggle_endpoint_changes_to_light_when_dark_by_default()
+ {
+ config()->set('setting-defaults.user.dark-mode-enabled', true);
+ $editor = $this->users->editor();
+
+ $this->assertEquals(true, setting()->getUser($editor, 'dark-mode-enabled'));
+ $prefChange = $this->actingAs($editor)->patch('/preferences/toggle-dark-mode');
+ $prefChange->assertRedirect();
+ $this->assertEquals(false, setting()->getUser($editor, 'dark-mode-enabled'));
+
+ $home = $this->get('/');
+ $this->withHtml($home)->assertElementNotExists('.dark-mode');
+ $home->assertDontSee('Light Mode');
+ $home->assertSee('Dark Mode');
+ }
+
public function test_books_view_type_preferences_when_list()
{
$editor = $this->users->editor();
diff --git a/tests/User/UserSearchTest.php b/tests/User/UserSearchTest.php
index 1387311ce..76efbf4af 100644
--- a/tests/User/UserSearchTest.php
+++ b/tests/User/UserSearchTest.php
@@ -57,8 +57,7 @@ class UserSearchTest extends TestCase
public function test_select_requires_logged_in_user()
{
$this->setSettings(['app-public' => true]);
- $defaultUser = User::getDefault();
- $this->permissions->grantUserRolePermissions($defaultUser, ['users-manage']);
+ $this->permissions->grantUserRolePermissions($this->users->guest(), ['users-manage']);
$resp = $this->get('/search/users/select?search=a');
$this->assertPermissionError($resp);
diff --git a/version b/version
index e05898580..05edb56cc 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-v23.06-dev
+v23.09-dev