diff --git a/.env.example b/.env.example index a0a1b72e683..4dee3b33441 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,10 @@ MAIL_FROM=bookstack@example.com # SMTP mail options # These settings can be checked using the "Send a Test Email" # feature found in the "Settings > Maintenance" area of the system. +# For more detailed documentation on mail options, refer to: +# https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration MAIL_HOST=localhost -MAIL_PORT=1025 +MAIL_PORT=587 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null diff --git a/.env.example.complete b/.env.example.complete index f81bccae470..96a3b448ff4 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -3,6 +3,10 @@ # Each option is shown with it's default value. # Do not copy this whole file to use as your '.env' file. +# The details here only serve as a quick reference. +# Please refer to the BookStack documentation for full details: +# https://www.bookstackapp.com/docs/ + # Application environment # Can be 'production', 'development', 'testing' or 'demo' APP_ENV=production @@ -65,22 +69,19 @@ DB_PASSWORD=database_user_password # certificate itself (Common Name or Subject Alternative Name). MYSQL_ATTR_SSL_CA="/path/to/ca.pem" -# Mail system to use -# Can be 'smtp' or 'sendmail' +# Mail configuration +# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration MAIL_DRIVER=smtp - -# Mail sending options MAIL_FROM=mail@bookstackapp.com MAIL_FROM_NAME=BookStack -# SMTP mail options MAIL_HOST=localhost -MAIL_PORT=1025 +MAIL_PORT=587 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null +MAIL_VERIFY_SSL=true -# Command to use when email is sent via sendmail MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs" # Cache & Session driver to use @@ -322,6 +323,13 @@ FILE_UPLOAD_SIZE_LIMIT=50 # Can be 'a4' or 'letter'. EXPORT_PAGE_SIZE=a4 +# Set path to wkhtmltopdf binary for PDF generation. +# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf' +# When false, BookStack will attempt to find a wkhtmltopdf in the application +# root folder then fall back to the default dompdf renderer if no binary exists. +# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections. +WKHTMLTOPDF=false + # Allow

Hello

"; + + $this->actingAsApiEditor(); + $page = $this->entities->page(); + $page->html = $html; + $page->save(); + + $resp = $this->getJson($this->baseEndpoint . "/{$page->id}"); + $this->assertEquals($html, $resp->json('raw_html')); + $this->assertNotEquals($html, $resp->json('html')); + } + + public function test_read_endpoint_returns_not_found() + { + $this->actingAsApiEditor(); + // get an id that is not used + $id = Page::orderBy('id', 'desc')->first()->id + 1; + $this->assertNull(Page::find($id)); + + $resp = $this->getJson($this->baseEndpoint . "/$id"); + + $resp->assertNotFound(); + $this->assertNull($resp->json('id')); + $resp->assertJsonIsObject('error'); + $resp->assertJsonStructure([ + 'error' => [ + 'code', + 'message', + ], + ]); + $this->assertSame(404, $resp->json('error')['code']); + } + public function test_update_endpoint() { $this->actingAsApiEditor(); diff --git a/tests/Api/RolesApiTest.php b/tests/Api/RolesApiTest.php index 515dabe68ff..d6ba22ab637 100644 --- a/tests/Api/RolesApiTest.php +++ b/tests/Api/RolesApiTest.php @@ -2,9 +2,9 @@ namespace Tests\Api; -use BookStack\Actions\ActivityType; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Activity\ActivityType; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Tests\TestCase; class RolesApiTest extends TestCase diff --git a/tests/Api/TestsApi.php b/tests/Api/TestsApi.php index 501f2875458..466acbffbe0 100644 --- a/tests/Api/TestsApi.php +++ b/tests/Api/TestsApi.php @@ -2,15 +2,28 @@ namespace Tests\Api; +use BookStack\Users\Models\User; + trait TestsApi { - protected $apiTokenId = 'apitoken'; - protected $apiTokenSecret = 'password'; + protected string $apiTokenId = 'apitoken'; + protected string $apiTokenSecret = 'password'; + + /** + * Set the given user as the current logged-in user via the API driver. + * This does not ensure API access. The user may still lack required role permissions. + */ + protected function actingAsForApi(User $user): static + { + parent::actingAs($user, 'api'); + + return $this; + } /** * Set the API editor role as the current user via the API driver. */ - protected function actingAsApiEditor() + protected function actingAsApiEditor(): static { $this->actingAs($this->users->editor(), 'api'); @@ -20,7 +33,7 @@ protected function actingAsApiEditor() /** * Set the API admin role as the current user via the API driver. */ - protected function actingAsApiAdmin() + protected function actingAsApiAdmin(): static { $this->actingAs($this->users->admin(), 'api'); diff --git a/tests/Api/UsersApiTest.php b/tests/Api/UsersApiTest.php index fadd2610c9b..e2a04b528ee 100644 --- a/tests/Api/UsersApiTest.php +++ b/tests/Api/UsersApiTest.php @@ -2,11 +2,13 @@ namespace Tests\Api; -use BookStack\Actions\ActivityType; -use BookStack\Auth\Role; -use BookStack\Auth\User; +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; use Illuminate\Support\Facades\Notification; use Tests\TestCase; @@ -67,6 +69,27 @@ public function test_index_endpoint_returns_expected_user() ]]); } + public function test_index_endpoint_has_correct_created_and_last_activity_dates() + { + $user = $this->users->editor(); + $user->created_at = now()->subYear(); + $user->save(); + + $this->actingAs($user); + Activity::add(ActivityType::AUTH_LOGIN, 'test login activity'); + /** @var ActivityModel $activity */ + $activity = ActivityModel::query()->where('user_id', '=', $user->id)->latest()->first(); + + $resp = $this->asAdmin()->getJson($this->baseEndpoint . '?filter[id]=3'); + $resp->assertJson(['data' => [ + [ + 'id' => $user->id, + 'created_at' => $user->created_at->toJSON(), + 'last_activity_at' => $activity->created_at->toJson(), + ], + ]]); + } + public function test_create_endpoint() { $this->actingAsApiAdmin(); diff --git a/tests/Auth/AuthTest.php b/tests/Auth/AuthTest.php index fe7e62568cf..0164978d85d 100644 --- a/tests/Auth/AuthTest.php +++ b/tests/Auth/AuthTest.php @@ -2,7 +2,7 @@ namespace Tests\Auth; -use BookStack\Auth\Access\Mfa\MfaSession; +use BookStack\Access\Mfa\MfaSession; use Illuminate\Testing\TestResponse; use Tests\TestCase; diff --git a/tests/Auth/GroupSyncServiceTest.php b/tests/Auth/GroupSyncServiceTest.php index dbf4110d871..fee2ae40686 100644 --- a/tests/Auth/GroupSyncServiceTest.php +++ b/tests/Auth/GroupSyncServiceTest.php @@ -2,9 +2,9 @@ namespace Tests\Auth; -use BookStack\Auth\Access\GroupSyncService; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Access\GroupSyncService; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Tests\TestCase; class GroupSyncServiceTest extends TestCase diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index cac2ea5e10f..34900ce6f70 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -2,23 +2,20 @@ namespace Tests\Auth; -use BookStack\Auth\Access\Ldap; -use BookStack\Auth\Access\LdapService; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Access\Ldap; +use BookStack\Access\LdapService; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Illuminate\Testing\TestResponse; use Mockery\MockInterface; use Tests\TestCase; class LdapTest extends TestCase { - /** - * @var MockInterface - */ - protected $mockLdap; + protected MockInterface $mockLdap; - protected $mockUser; - protected $resourceId = 'resource-test'; + protected User $mockUser; + protected string $resourceId = 'resource-test'; protected function setUp(): void { @@ -40,8 +37,7 @@ protected function setUp(): void 'services.ldap.tls_insecure' => false, 'services.ldap.thumbnail_attribute' => null, ]); - $this->mockLdap = \Mockery::mock(Ldap::class); - $this->app[Ldap::class] = $this->mockLdap; + $this->mockLdap = $this->mock(Ldap::class); $this->mockUser = User::factory()->make(); } @@ -96,7 +92,7 @@ public function test_login() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), ]]); $resp = $this->mockUserLogin(); @@ -127,7 +123,7 @@ public function test_email_domain_restriction_active_on_new_ldap_login() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), ]]); $resp = $this->mockUserLogin(); @@ -190,7 +186,7 @@ public function test_initial_incorrect_credentials() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), ]]); $this->mockLdap->shouldReceive('bind')->times(2)->andReturn(true, false); @@ -283,7 +279,7 @@ public function test_login_maps_roles_and_retains_existing_roles() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), 'mail' => [$this->mockUser->email], 'memberof' => [ 'count' => 2, @@ -328,7 +324,7 @@ public function test_login_maps_roles_and_removes_old_roles_if_set() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), 'mail' => [$this->mockUser->email], 'memberof' => [ 'count' => 1, @@ -429,7 +425,7 @@ public function test_login_maps_roles_using_external_auth_ids_if_set() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), 'mail' => [$this->mockUser->email], 'memberof' => [ 'count' => 1, @@ -470,7 +466,7 @@ public function test_login_group_mapping_does_not_conflict_with_default_role() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), 'mail' => [$this->mockUser->email], 'memberof' => [ 'count' => 2, @@ -504,7 +500,7 @@ public function test_login_uses_specified_display_name_attribute() ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), 'displayname' => 'displayNameAttribute', ]]); @@ -529,7 +525,7 @@ public function test_login_uses_default_display_name_attribute_if_specified_not_ ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), ]]); $this->mockUserLogin()->assertRedirect('/login'); @@ -546,39 +542,33 @@ public function test_login_uses_default_display_name_attribute_if_specified_not_ ]); } - protected function checkLdapReceivesCorrectDetails($serverString, $expectedHost, $expectedPort) + protected function checkLdapReceivesCorrectDetails($serverString, $expectedHostString): void { - app('config')->set([ - 'services.ldap.server' => $serverString, - ]); + app('config')->set(['services.ldap.server' => $serverString]); - // Standard mocks - $this->commonLdapMocks(0, 1, 1, 2, 1); - $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)->andReturn(['count' => 1, 0 => [ - 'uid' => [$this->mockUser->name], - 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], - ]]); + $this->mockLdap->shouldReceive('connect') + ->once() + ->with($expectedHostString) + ->andReturn(false); - $this->mockLdap->shouldReceive('connect')->once() - ->with($expectedHost, $expectedPort)->andReturn($this->resourceId); $this->mockUserLogin(); } - public function test_ldap_port_provided_on_host_if_host_is_full_uri() - { - $hostName = 'ldaps://bookstack:8080'; - $this->checkLdapReceivesCorrectDetails($hostName, $hostName, 389); - } - - public function test_ldap_port_parsed_from_server_if_host_is_not_full_uri() + public function test_ldap_receives_correct_connect_host_from_config() { - $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com:8080', 'ldap.bookstack.com', 8080); - } + $expectedResultByInput = [ + 'ldaps://bookstack:8080' => 'ldaps://bookstack:8080', + 'ldap.bookstack.com:8080' => 'ldap://ldap.bookstack.com:8080', + 'ldap.bookstack.com' => 'ldap://ldap.bookstack.com', + 'ldaps://ldap.bookstack.com' => 'ldaps://ldap.bookstack.com', + 'ldaps://ldap.bookstack.com ldap://a.b.com' => 'ldaps://ldap.bookstack.com ldap://a.b.com', + ]; - public function test_default_ldap_port_used_if_not_in_server_string_and_not_uri() - { - $this->checkLdapReceivesCorrectDetails('ldap.bookstack.com', 'ldap.bookstack.com', 389); + foreach ($expectedResultByInput as $input => $expectedResult) { + $this->checkLdapReceivesCorrectDetails($input, $expectedResult); + $this->refreshApplication(); + $this->setUp(); + } } public function test_forgot_password_routes_inaccessible() @@ -626,7 +616,7 @@ public function test_dump_user_details_option_works() 'cn' => [$this->mockUser->name], // Test dumping binary data for avatar responses 'jpegphoto' => base64_decode('/9j/4AAQSkZJRg=='), - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), ]]); $resp = $this->post('/login', [ @@ -665,7 +655,7 @@ public function test_ldap_attributes_can_be_binary_decoded_if_marked() ->andReturn(['count' => 1, 0 => [ 'uid' => [hex2bin('FFF8F7')], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), ]]); $details = $ldapService->getUserDetails('test'); @@ -680,12 +670,12 @@ public function test_new_ldap_user_login_with_already_used_email_address_shows_e ->andReturn(['count' => 1, 0 => [ 'uid' => [$this->mockUser->name], 'cn' => [$this->mockUser->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), 'mail' => 'tester@example.com', ]], ['count' => 1, 0 => [ 'uid' => ['Barry'], 'cn' => ['Scott'], - 'dn' => ['dc=bscott' . config('services.ldap.base_dn')], + 'dn' => 'dc=bscott' . config('services.ldap.base_dn'), 'mail' => 'tester@example.com', ]]); @@ -716,7 +706,7 @@ public function test_login_with_email_confirmation_required_maps_groups_but_show ->andReturn(['count' => 1, 0 => [ 'uid' => [$user->name], 'cn' => [$user->name], - 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), 'mail' => [$user->email], 'memberof' => [ 'count' => 1, diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index fb941f00b7d..1f359b41a10 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -2,10 +2,10 @@ namespace Tests\Auth; -use BookStack\Actions\ActivityType; -use BookStack\Auth\Access\Mfa\MfaValue; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Access\Mfa\MfaValue; +use BookStack\Activity\ActivityType; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; diff --git a/tests/Auth/MfaVerificationTest.php b/tests/Auth/MfaVerificationTest.php index e2325031490..2fa272e331c 100644 --- a/tests/Auth/MfaVerificationTest.php +++ b/tests/Auth/MfaVerificationTest.php @@ -2,12 +2,12 @@ namespace Tests\Auth; -use BookStack\Auth\Access\LoginService; -use BookStack\Auth\Access\Mfa\MfaValue; -use BookStack\Auth\Access\Mfa\TotpService; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Access\LoginService; +use BookStack\Access\Mfa\MfaValue; +use BookStack\Access\Mfa\TotpService; use BookStack\Exceptions\StoppedAuthenticationException; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Illuminate\Support\Facades\Hash; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; diff --git a/tests/Auth/OidcTest.php b/tests/Auth/OidcTest.php index 35acb775216..191a25f8801 100644 --- a/tests/Auth/OidcTest.php +++ b/tests/Auth/OidcTest.php @@ -2,9 +2,11 @@ namespace Tests\Auth; -use BookStack\Actions\ActivityType; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Activity\ActivityType; +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; @@ -397,7 +399,6 @@ public function test_auth_uses_configured_external_id_claim_option() config()->set([ 'oidc.external_id_claim' => 'super_awesome_id', ]); - $roleA = Role::factory()->create(['display_name' => 'Wizards']); $resp = $this->runLogin([ 'email' => 'benny@example.com', @@ -464,6 +465,60 @@ public function test_login_group_sync_with_nested_groups_in_token() $this->assertTrue($user->hasRole($roleA->id)); } + public function test_oidc_id_token_pre_validate_theme_event_without_return() + { + $args = []; + $callback = function (...$eventArgs) use (&$args) { + $args = $eventArgs; + }; + Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + 'name' => 'Benny', + ]); + $resp->assertRedirect('/'); + + $this->assertDatabaseHas('users', [ + 'external_auth_id' => 'benny1010101', + ]); + + $this->assertArrayHasKey('iss', $args[0]); + $this->assertArrayHasKey('sub', $args[0]); + $this->assertEquals('Benny', $args[0]['name']); + $this->assertEquals('benny1010101', $args[0]['sub']); + + $this->assertArrayHasKey('access_token', $args[1]); + $this->assertArrayHasKey('expires_in', $args[1]); + $this->assertArrayHasKey('refresh_token', $args[1]); + } + + public function test_oidc_id_token_pre_validate_theme_event_with_return() + { + $callback = function (...$eventArgs) { + return array_merge($eventArgs[0], [ + 'email' => 'lenny@example.com', + 'sub' => 'lenny1010101', + 'name' => 'Lenny', + ]); + }; + Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $callback); + + $resp = $this->runLogin([ + 'email' => 'benny@example.com', + 'sub' => 'benny1010101', + 'name' => 'Benny', + ]); + $resp->assertRedirect('/'); + + $this->assertDatabaseHas('users', [ + 'email' => 'lenny@example.com', + 'external_auth_id' => 'lenny1010101', + 'name' => 'Lenny', + ]); + } + protected function withAutodiscovery() { config()->set([ diff --git a/tests/Auth/RegistrationTest.php b/tests/Auth/RegistrationTest.php index 5c3aab6a8ba..bc190afd81f 100644 --- a/tests/Auth/RegistrationTest.php +++ b/tests/Auth/RegistrationTest.php @@ -2,9 +2,9 @@ namespace Tests\Auth; -use BookStack\Auth\Role; -use BookStack\Auth\User; use BookStack\Notifications\ConfirmEmail; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Notification; use Tests\TestCase; diff --git a/tests/Auth/ResetPasswordTest.php b/tests/Auth/ResetPasswordTest.php index 72e26f10ceb..b97a2f2d380 100644 --- a/tests/Auth/ResetPasswordTest.php +++ b/tests/Auth/ResetPasswordTest.php @@ -2,8 +2,8 @@ namespace Tests\Auth; -use BookStack\Auth\User; use BookStack\Notifications\ResetPassword; +use BookStack\Users\Models\User; use Illuminate\Support\Facades\Notification; use Tests\TestCase; diff --git a/tests/Auth/Saml2Test.php b/tests/Auth/Saml2Test.php index 0ee419610ca..801682a003c 100644 --- a/tests/Auth/Saml2Test.php +++ b/tests/Auth/Saml2Test.php @@ -2,8 +2,8 @@ namespace Tests\Auth; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Tests\TestCase; class Saml2Test extends TestCase @@ -193,6 +193,9 @@ public function test_logout_sls_flow() $req = $this->post('/saml2/logout'); $redirect = $req->headers->get('location'); $this->assertStringStartsWith('http://saml.local/saml2/idp/SingleLogoutService.php', $redirect); + $sloData = $this->parseSamlDataFromUrl($redirect, 'SAMLRequest'); + $this->assertStringContainsString('_4fe7c0d1572d64b27f930aa6f236a6f42e930901cc', $sloData); + $this->withGet(['SAMLResponse' => $this->sloResponseData], $handleLogoutResponse); } @@ -379,11 +382,16 @@ protected function getAuthnRequest(): string { $req = $this->post('/saml2/login'); $location = $req->headers->get('Location'); - $query = explode('?', $location)[1]; + return $this->parseSamlDataFromUrl($location, 'SAMLRequest'); + } + + protected function parseSamlDataFromUrl(string $url, string $paramName): string + { + $query = explode('?', $url)[1]; $params = []; parse_str($query, $params); - return gzinflate(base64_decode($params['SAMLRequest'])); + return gzinflate(base64_decode($params[$paramName])); } protected function withGet(array $options, callable $callback) diff --git a/tests/Auth/SocialAuthTest.php b/tests/Auth/SocialAuthTest.php index 24deedd5f34..5b7071a0779 100644 --- a/tests/Auth/SocialAuthTest.php +++ b/tests/Auth/SocialAuthTest.php @@ -2,9 +2,9 @@ namespace Tests\Auth; -use BookStack\Actions\ActivityType; -use BookStack\Auth\SocialAccount; -use BookStack\Auth\User; +use BookStack\Access\SocialAccount; +use BookStack\Activity\ActivityType; +use BookStack\Users\Models\User; use Illuminate\Support\Facades\DB; use Laravel\Socialite\Contracts\Factory; use Laravel\Socialite\Contracts\Provider; diff --git a/tests/Auth/UserInviteTest.php b/tests/Auth/UserInviteTest.php index e82ce463876..8d6143877d3 100644 --- a/tests/Auth/UserInviteTest.php +++ b/tests/Auth/UserInviteTest.php @@ -2,9 +2,9 @@ namespace Tests\Auth; -use BookStack\Auth\Access\UserInviteService; -use BookStack\Auth\User; +use BookStack\Access\UserInviteService; use BookStack\Notifications\UserInvite; +use BookStack\Users\Models\User; use Carbon\Carbon; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Support\Facades\DB; @@ -54,7 +54,7 @@ public function test_user_invite_sent_in_selected_language() /** @var MailMessage $mail */ $mail = $notification->toMail($notifiable); - return 'Sie wurden eingeladen BookStack beizutreten!' === $mail->subject && + return 'Sie wurden eingeladen, BookStack beizutreten!' === $mail->subject && 'Ein Konto wurde für Sie auf BookStack erstellt.' === $mail->greeting; }); } diff --git a/tests/Commands/CleanupImagesCommandTest.php b/tests/Commands/CleanupImagesCommandTest.php new file mode 100644 index 00000000000..a1a5ab98540 --- /dev/null +++ b/tests/Commands/CleanupImagesCommandTest.php @@ -0,0 +1,49 @@ +entities->page(); + $image = Image::factory()->create(['uploaded_to' => $page->id]); + + $this->artisan('bookstack:cleanup-images -v') + ->expectsOutput('Dry run, no images have been deleted') + ->expectsOutput('1 images found that would have been deleted') + ->expectsOutputToContain($image->path) + ->assertExitCode(0); + + $this->assertDatabaseHas('images', ['id' => $image->id]); + } + + public function test_command_force_run() + { + $page = $this->entities->page(); + $image = Image::factory()->create(['uploaded_to' => $page->id]); + + $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') + ->assertExitCode(0); + + $this->assertDatabaseMissing('images', ['id' => $image->id]); + } + + public function test_command_force_run_negative_confirmation() + { + $page = $this->entities->page(); + $image = Image::factory()->create(['uploaded_to' => $page->id]); + + $this->artisan('bookstack:cleanup-images --force') + ->expectsConfirmation('Are you sure you want to proceed?', 'no') + ->assertExitCode(0); + + $this->assertDatabaseHas('images', ['id' => $image->id]); + } +} diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php index b2624e23df2..410a39aa870 100644 --- a/tests/Commands/ClearActivityCommandTest.php +++ b/tests/Commands/ClearActivityCommandTest.php @@ -2,7 +2,7 @@ namespace Tests\Commands; -use BookStack\Actions\ActivityType; +use BookStack\Activity\ActivityType; use BookStack\Facades\Activity; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; diff --git a/tests/Commands/CopyShelfPermissionsCommandTest.php b/tests/Commands/CopyShelfPermissionsCommandTest.php index c4b9fe6f305..5c21a2e341c 100644 --- a/tests/Commands/CopyShelfPermissionsCommandTest.php +++ b/tests/Commands/CopyShelfPermissionsCommandTest.php @@ -11,7 +11,7 @@ public function test_copy_shelf_permissions_command_shows_error_when_no_required { $this->artisan('bookstack:copy-shelf-permissions') ->expectsOutput('Either a --slug or --all option must be provided.') - ->assertExitCode(0); + ->assertExitCode(1); } public function test_copy_shelf_permissions_command_using_slug() diff --git a/tests/Commands/CreateAdminCommandTest.php b/tests/Commands/CreateAdminCommandTest.php index 1d8915b3a25..95a39c497e4 100644 --- a/tests/Commands/CreateAdminCommandTest.php +++ b/tests/Commands/CreateAdminCommandTest.php @@ -2,7 +2,7 @@ namespace Tests\Commands; -use BookStack\Auth\User; +use BookStack\Users\Models\User; use Illuminate\Support\Facades\Auth; use Tests\TestCase; diff --git a/tests/Commands/DeleteUsersCommandTest.php b/tests/Commands/DeleteUsersCommandTest.php new file mode 100644 index 00000000000..a959df95dfe --- /dev/null +++ b/tests/Commands/DeleteUsersCommandTest.php @@ -0,0 +1,44 @@ +count(); + $normalUsers = $this->getNormalUsers(); + + $normalUserCount = $userCount - count($normalUsers); + $this->artisan('bookstack:delete-users') + ->expectsConfirmation('Are you sure you want to continue?', 'yes') + ->expectsOutputToContain("Deleted $normalUserCount of $userCount total users.") + ->assertExitCode(0); + + $this->assertDatabaseMissing('users', ['id' => $normalUsers->first()->id]); + } + + public function test_command_requires_confirmation() + { + $normalUsers = $this->getNormalUsers(); + + $this->artisan('bookstack:delete-users') + ->expectsConfirmation('Are you sure you want to continue?', 'no') + ->assertExitCode(0); + + $this->assertDatabaseHas('users', ['id' => $normalUsers->first()->id]); + } + + protected function getNormalUsers(): Collection + { + return User::query()->whereNull('system_name') + ->get() + ->filter(function (User $user) { + return !$user->hasSystemRole('admin'); + }); + } +} diff --git a/tests/Commands/RegenerateCommentContentCommandTest.php b/tests/Commands/RegenerateCommentContentCommandTest.php index 08f137777d1..4940d66c343 100644 --- a/tests/Commands/RegenerateCommentContentCommandTest.php +++ b/tests/Commands/RegenerateCommentContentCommandTest.php @@ -2,7 +2,7 @@ namespace Tests\Commands; -use BookStack\Actions\Comment; +use BookStack\Activity\Models\Comment; use Tests\TestCase; class RegenerateCommentContentCommandTest extends TestCase diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php index 9cf7dec93d6..75c6c1b3851 100644 --- a/tests/Commands/RegeneratePermissionsCommandTest.php +++ b/tests/Commands/RegeneratePermissionsCommandTest.php @@ -3,8 +3,7 @@ namespace Tests\Commands; use BookStack\Auth\Permissions\CollapsedPermission; -use BookStack\Auth\Permissions\EntityPermission; -use BookStack\Auth\Permissions\JointPermission; +use BookStack\Permissions\Models\JointPermission; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Tests\TestCase; diff --git a/tests/Commands/RegenerateSearchCommandTest.php b/tests/Commands/RegenerateSearchCommandTest.php new file mode 100644 index 00000000000..418327ebe1d --- /dev/null +++ b/tests/Commands/RegenerateSearchCommandTest.php @@ -0,0 +1,29 @@ +entities->page(); + SearchTerm::truncate(); + + $this->assertDatabaseMissing('search_terms', ['entity_id' => $page->id]); + + $this->artisan('bookstack:regenerate-search') + ->expectsOutput('Search index regenerated!') + ->assertExitCode(0); + + $this->assertDatabaseHas('search_terms', [ + 'entity_type' => 'page', + 'entity_id' => $page->id + ]); + DB::beginTransaction(); + } +} diff --git a/tests/Commands/ResetMfaCommandTest.php b/tests/Commands/ResetMfaCommandTest.php index e65a048ef92..85f8f6430a7 100644 --- a/tests/Commands/ResetMfaCommandTest.php +++ b/tests/Commands/ResetMfaCommandTest.php @@ -2,8 +2,8 @@ namespace Tests\Commands; -use BookStack\Auth\Access\Mfa\MfaValue; -use BookStack\Auth\User; +use BookStack\Access\Mfa\MfaValue; +use BookStack\Users\Models\User; use Tests\TestCase; class ResetMfaCommandTest extends TestCase diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index 1788e9452a6..280c81febd1 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -2,6 +2,7 @@ namespace Tests\Commands; +use Illuminate\Support\Facades\Artisan; use Symfony\Component\Console\Exception\RuntimeException; use Tests\TestCase; @@ -34,6 +35,13 @@ public function test_command_requires_valid_url() $this->artisan('bookstack:update-url https://cats.example.com'); } + public function test_command_force_option_skips_prompt() + { + $this->artisan('bookstack:update-url --force https://cats.example.com/donkey https://cats.example.com/monkey') + ->expectsOutputToContain('URL update procedure complete') + ->assertSuccessful(); + } + public function test_command_updates_settings() { setting()->put('my-custom-item', 'https://example.com/donkey/cat'); diff --git a/tests/Commands/UpgradeDatabaseEncodingCommandTest.php b/tests/Commands/UpgradeDatabaseEncodingCommandTest.php new file mode 100644 index 00000000000..b7fe4eb6c7f --- /dev/null +++ b/tests/Commands/UpgradeDatabaseEncodingCommandTest.php @@ -0,0 +1,15 @@ +artisan('bookstack:db-utf8mb4') + ->expectsOutputToContain('ALTER DATABASE') + ->expectsOutputToContain('ALTER TABLE `users` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;'); + } +} diff --git a/tests/DebugViewTest.php b/tests/DebugViewTest.php index ec66de10a50..43de9f175e9 100644 --- a/tests/DebugViewTest.php +++ b/tests/DebugViewTest.php @@ -2,7 +2,7 @@ namespace Tests; -use BookStack\Auth\Access\SocialAuthService; +use BookStack\Access\SocialAuthService; use Illuminate\Testing\TestResponse; class DebugViewTest extends TestCase diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index 2e7d41d643a..c1842c175a7 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -2,10 +2,10 @@ namespace Tests\Entity; -use BookStack\Auth\User; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Uploads\Image; +use BookStack\Users\Models\User; use Illuminate\Support\Str; use Tests\TestCase; @@ -196,6 +196,31 @@ public function test_shelf_view_sort_takes_action() $this->withHtml($resp)->assertElementContains('.book-content a.grid-card:nth-child(3)', 'adsfsdfsdfsd'); } + public function test_shelf_view_sorts_by_name_case_insensitively() + { + $shelf = Bookshelf::query()->whereHas('books')->with('books')->first(); + $books = Book::query()->take(3)->get(['id', 'name']); + $books[0]->fill(['name' => 'Book Ab'])->save(); + $books[1]->fill(['name' => 'Book ac'])->save(); + $books[2]->fill(['name' => 'Book AD'])->save(); + + // Set book ordering + $this->asAdmin()->put($shelf->getUrl(), [ + 'books' => $books->implode('id', ','), + 'tags' => [], 'description' => 'abc', 'name' => 'abc', + ]); + $this->assertEquals(3, $shelf->books()->count()); + $shelf->refresh(); + + setting()->putUser($this->users->editor(), 'shelf_books_sort', 'name'); + setting()->putUser($this->users->editor(), 'shelf_books_sort_order', 'asc'); + $html = $this->withHtml($this->asEditor()->get($shelf->getUrl())); + + $html->assertElementContains('.book-content a.grid-card:nth-child(1)', 'Book Ab'); + $html->assertElementContains('.book-content a.grid-card:nth-child(2)', 'Book ac'); + $html->assertElementContains('.book-content a.grid-card:nth-child(3)', 'Book AD'); + } + public function test_shelf_edit() { $shelf = $this->entities->shelf(); diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php index 99e3525a0b9..b3e9f3cd0ed 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -2,7 +2,7 @@ namespace Tests\Entity; -use BookStack\Actions\Comment; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Page; use Tests\TestCase; @@ -114,4 +114,35 @@ public function test_html_cannot_be_injected_via_comment_content() $pageView->assertDontSee($script, false); $pageView->assertSee('sometextinthecommentupdated'); } + + public function test_reply_comments_are_nested() + { + $this->asAdmin(); + $page = $this->entities->page(); + + $this->postJson("/comment/$page->id", ['text' => 'My new comment']); + $this->postJson("/comment/$page->id", ['text' => 'My new comment']); + + $respHtml = $this->withHtml($this->get($page->getUrl())); + $respHtml->assertElementCount('.comment-branch', 3); + $respHtml->assertElementNotExists('.comment-branch .comment-branch'); + + $comment = $page->comments()->first(); + $resp = $this->postJson("/comment/$page->id", ['text' => 'My nested comment', 'parent_id' => $comment->local_id]); + $resp->assertStatus(200); + + $respHtml = $this->withHtml($this->get($page->getUrl())); + $respHtml->assertElementCount('.comment-branch', 4); + $respHtml->assertElementContains('.comment-branch .comment-branch', 'My nested comment'); + } + + public function test_comments_are_visible_in_the_page_editor() + { + $page = $this->entities->page(); + + $this->asAdmin()->postJson("/comment/$page->id", ['text' => 'My great comment to see in the editor']); + + $respHtml = $this->withHtml($this->get($page->getUrl('/edit'))); + $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor'); + } } diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index 4beec7fa61d..decda52243f 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -2,8 +2,8 @@ namespace Tests\Entity; -use BookStack\Actions\ActivityType; -use BookStack\Actions\Tag; +use BookStack\Activity\ActivityType; +use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; diff --git a/tests/Entity/EntityAccessTest.php b/tests/Entity/EntityAccessTest.php index ab7587a3b27..2b7e5d5ddac 100644 --- a/tests/Entity/EntityAccessTest.php +++ b/tests/Entity/EntityAccessTest.php @@ -2,8 +2,8 @@ namespace Tests\Entity; -use BookStack\Auth\UserRepo; use BookStack\Entities\Models\Entity; +use BookStack\Users\UserRepo; use Tests\TestCase; class EntityAccessTest extends TestCase diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 4563fb651ea..a070ce3fa88 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -2,9 +2,10 @@ namespace Tests\Entity; -use BookStack\Actions\Tag; +use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Models\Chapter; use Tests\TestCase; class EntitySearchTest extends TestCase @@ -225,6 +226,17 @@ public function test_entity_selector_search_shows_breadcrumbs() $chapterSearch->assertSee($chapter->book->getShortName(42)); } + public function test_entity_selector_shows_breadcrumbs_on_default_view() + { + $page = $this->entities->pageWithinChapter(); + $this->asEditor()->get($page->chapter->getUrl()); + + $resp = $this->asEditor()->get('/search/entity-selector?types=book,chapter&permission=page-create'); + $html = $this->withHtml($resp); + $html->assertElementContains('.chapter.entity-list-item', $page->chapter->name); + $html->assertElementContains('.chapter.entity-list-item .entity-item-snippet', $page->book->getShortName(42)); + } + public function test_entity_selector_search_reflects_items_without_permission() { $page = $this->entities->page(); @@ -444,6 +456,26 @@ public function test_words_adjacent_to_lines_breaks_can_be_matched_with_normal_t $search->assertSee($page->getUrl(), false); } + public function test_backslashes_can_be_searched_upon() + { + $page = $this->entities->newPage(['name' => 'TermA', 'html' => ' +

More info is at the path \\\\cat\\dog\\badger

+ ']); + $page->tags()->save(new Tag(['name' => '\\Category', 'value' => '\\animals\\fluffy'])); + + $search = $this->asEditor()->get('/search?term=' . urlencode('\\\\cat\\dog')); + $search->assertSee($page->getUrl(), false); + + $search = $this->asEditor()->get('/search?term=' . urlencode('"\\dog\\"')); + $search->assertSee($page->getUrl(), false); + + $search = $this->asEditor()->get('/search?term=' . urlencode('"\\badger\\"')); + $search->assertDontSee($page->getUrl(), false); + + $search = $this->asEditor()->get('/search?term=' . urlencode('[\\Categorylike%\\fluffy]')); + $search->assertSee($page->getUrl(), false); + } + public function test_searches_with_user_filters_adds_them_into_advanced_search_form() { $resp = $this->asEditor()->get('/search?term=' . urlencode('test {updated_by:dan} {created_by:dan}')); diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 6ae8dcde313..2b5244bf010 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -2,7 +2,6 @@ namespace Tests\Entity; -use BookStack\Auth\Role; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index 6d6224abf4b..d8845fe1276 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -108,6 +108,18 @@ public function test_page_includes_rendered_on_book_export() $htmlContent->assertSee('my cat is awesome and scratchy'); } + public function test_page_includes_can_be_nested_up_to_three_times() + { + $page = $this->entities->page(); + $tag = "{{@{$page->id}#bkmrk-test}}"; + $page->html = '

Hello Barry ' . $tag . '

'; + $page->save(); + + $pageResp = $this->asEditor()->get($page->getUrl()); + $this->withHtml($pageResp)->assertElementContains('#bkmrk-test', 'Hello Barry Hello Barry Hello Barry Hello Barry ' . $tag); + $this->withHtml($pageResp)->assertElementNotContains('#bkmrk-test', 'Hello Barry Hello Barry Hello Barry Hello Barry Hello Barry ' . $tag); + } + public function test_page_content_scripts_removed_by_default() { $this->asEditor(); diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index 75b1933ea0e..e99ba9b8189 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -166,6 +166,30 @@ public function test_page_html_in_ajax_fetch_response() ]); } + public function test_user_draft_removed_on_user_drafts_delete_call() + { + $editor = $this->users->editor(); + $page = $this->entities->page(); + + $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [ + 'name' => $page->name, + 'html' => '

updated draft again

', + ]); + + $revisionData = [ + 'type' => 'update_draft', + 'created_by' => $editor->id, + 'page_id' => $page->id, + ]; + + $this->assertDatabaseHas('page_revisions', $revisionData); + + $resp = $this->delete("/page-revisions/user-drafts/{$page->id}"); + + $resp->assertOk(); + $this->assertDatabaseMissing('page_revisions', $revisionData); + } + public function test_updating_page_draft_with_markdown_retains_markdown_content() { $book = $this->entities->book(); diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 0df37728e19..97d5a6664da 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -2,7 +2,7 @@ namespace Tests\Entity; -use BookStack\Actions\ActivityType; +use BookStack\Activity\ActivityType; use BookStack\Entities\Models\Page; use Tests\TestCase; diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index 370c4381c63..daad82e76dc 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -50,6 +50,13 @@ public function test_page_view_when_creator_is_deleted_but_owner_exists() $resp->assertSeeText('Owned by ' . $owner->name); } + public function test_page_show_includes_pointer_section_select_mode_button() + { + $page = $this->entities->page(); + $resp = $this->asEditor()->get($page->getUrl()); + $this->withHtml($resp)->assertElementContains('.content-wrap button.screen-reader-only', 'Enter section select mode'); + } + public function test_page_creation_with_markdown_content() { $this->setSettings(['app-editor' => 'markdown']); diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php index 7e667495908..c1240e95538 100644 --- a/tests/Entity/TagTest.php +++ b/tests/Entity/TagTest.php @@ -2,7 +2,7 @@ namespace Tests\Entity; -use BookStack\Actions\Tag; +use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use Tests\TestCase; diff --git a/tests/FavouriteTest.php b/tests/FavouriteTest.php index 7778aa8e935..0e30cbd58ca 100644 --- a/tests/FavouriteTest.php +++ b/tests/FavouriteTest.php @@ -2,8 +2,8 @@ namespace Tests; -use BookStack\Actions\Favourite; -use BookStack\Auth\User; +use BookStack\Activity\Models\Favourite; +use BookStack\Users\Models\User; class FavouriteTest extends TestCase { diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index 8b045db54a2..ddc854290b1 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -2,7 +2,6 @@ namespace Tests\Helpers; -use BookStack\Auth\User; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; @@ -12,6 +11,7 @@ use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; +use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; /** diff --git a/tests/Helpers/PermissionsProvider.php b/tests/Helpers/PermissionsProvider.php index b93c45e25e0..512f43fb6cc 100644 --- a/tests/Helpers/PermissionsProvider.php +++ b/tests/Helpers/PermissionsProvider.php @@ -2,11 +2,11 @@ namespace Tests\Helpers; -use BookStack\Auth\Permissions\EntityPermission; -use BookStack\Auth\Permissions\RolePermission; -use BookStack\Auth\Role; -use BookStack\Auth\User; use BookStack\Entities\Models\Entity; +use BookStack\Permissions\Models\EntityPermission; +use BookStack\Permissions\Models\RolePermission; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; class PermissionsProvider { diff --git a/tests/Helpers/UserRoleProvider.php b/tests/Helpers/UserRoleProvider.php index 8c2718bc3e2..b86e9039407 100644 --- a/tests/Helpers/UserRoleProvider.php +++ b/tests/Helpers/UserRoleProvider.php @@ -2,9 +2,9 @@ namespace Tests\Helpers; -use BookStack\Auth\Permissions\PermissionsRepo; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Permissions\PermissionsRepo; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; class UserRoleProvider { diff --git a/tests/HomepageTest.php b/tests/HomepageTest.php index c7e8b69bb2c..eb552b2e209 100644 --- a/tests/HomepageTest.php +++ b/tests/HomepageTest.php @@ -2,8 +2,8 @@ namespace Tests; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; class HomepageTest extends TestCase { diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php index b65227dd826..a66227ff2e8 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -2,6 +2,8 @@ namespace Tests; +use BookStack\Activity\ActivityType; + class LanguageTest extends TestCase { protected array $langs; @@ -90,4 +92,12 @@ public function test_unknown_lang_does_not_break_app() $loginReq->assertOk(); $loginReq->assertSee('Log In'); } + + public function test_all_activity_types_have_activity_text() + { + foreach (ActivityType::all() as $activityType) { + $langKey = 'activities.' . $activityType; + $this->assertNotEquals($langKey, trans($langKey, [], 'en')); + } + } } diff --git a/tests/Permissions/EntityOwnerChangeTest.php b/tests/Permissions/EntityOwnerChangeTest.php index e94759760ce..f002549220b 100644 --- a/tests/Permissions/EntityOwnerChangeTest.php +++ b/tests/Permissions/EntityOwnerChangeTest.php @@ -2,7 +2,7 @@ namespace Tests\Permissions; -use BookStack\Auth\User; +use BookStack\Users\Models\User; use Tests\TestCase; class EntityOwnerChangeTest extends TestCase diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php index 99a8bd88c1d..6ea0257b81e 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -2,13 +2,13 @@ namespace Tests\Permissions; -use BookStack\Auth\Role; -use BookStack\Auth\User; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Exception; use Illuminate\Support\Str; use Tests\TestCase; @@ -413,6 +413,16 @@ public function test_page_restriction_form() $this->entityRestrictionFormTest(Page::class, 'Page Permissions', 'delete', '2'); } + public function test_shelf_create_permission_visible_with_notice() + { + $shelf = $this->entities->shelf(); + + $resp = $this->asAdmin()->get($shelf->getUrl('/permissions')); + $html = $this->withHtml($resp); + $html->assertElementExists('input[name$="[create]"]'); + $resp->assertSee('Shelf create permissions are only used for copying permissions to child books using the action below.'); + } + public function test_restricted_pages_not_visible_in_book_navigation_on_pages() { $chapter = $this->entities->chapter(); diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolePermissionsTest.php similarity index 73% rename from tests/Permissions/RolesTest.php rename to tests/Permissions/RolePermissionsTest.php index 971479e28ae..0b2e1668655 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolePermissionsTest.php @@ -2,20 +2,20 @@ namespace Tests\Permissions; -use BookStack\Actions\ActivityType; -use BookStack\Actions\Comment; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Activity\ActivityType; +use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Uploads\Image; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Illuminate\Testing\TestResponse; use Tests\TestCase; -class RolesTest extends TestCase +class RolePermissionsTest extends TestCase { protected User $user; @@ -25,208 +25,6 @@ protected function setUp(): void $this->user = $this->users->viewer(); } - public function test_admin_can_see_settings() - { - $this->asAdmin()->get('/settings/features')->assertSee('Settings'); - } - - public function test_cannot_delete_admin_role() - { - $adminRole = Role::getRole('admin'); - $deletePageUrl = '/settings/roles/delete/' . $adminRole->id; - - $this->asAdmin()->get($deletePageUrl); - $this->delete($deletePageUrl)->assertRedirect($deletePageUrl); - $this->get($deletePageUrl)->assertSee('cannot be deleted'); - } - - public function test_role_cannot_be_deleted_if_default() - { - $newRole = $this->users->createRole(); - $this->setSettings(['registration-role' => $newRole->id]); - - $deletePageUrl = '/settings/roles/delete/' . $newRole->id; - $this->asAdmin()->get($deletePageUrl); - $this->delete($deletePageUrl)->assertRedirect($deletePageUrl); - $this->get($deletePageUrl)->assertSee('cannot be deleted'); - } - - public function test_role_create_update_delete_flow() - { - $testRoleName = 'Test Role'; - $testRoleDesc = 'a little test description'; - $testRoleUpdateName = 'An Super Updated role'; - - // Creation - $resp = $this->asAdmin()->get('/settings/features'); - $this->withHtml($resp)->assertElementContains('a[href="' . url('/settings/roles') . '"]', 'Roles'); - - $resp = $this->get('/settings/roles'); - $this->withHtml($resp)->assertElementContains('a[href="' . url('/settings/roles/new') . '"]', 'Create New Role'); - - $resp = $this->get('/settings/roles/new'); - $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/roles/new') . '"]', 'Save Role'); - - $resp = $this->post('/settings/roles/new', [ - 'display_name' => $testRoleName, - 'description' => $testRoleDesc, - ]); - $resp->assertRedirect('/settings/roles'); - - $resp = $this->get('/settings/roles'); - $resp->assertSee($testRoleName); - $resp->assertSee($testRoleDesc); - $this->assertDatabaseHas('roles', [ - 'display_name' => $testRoleName, - 'description' => $testRoleDesc, - 'mfa_enforced' => false, - ]); - - /** @var Role $role */ - $role = Role::query()->where('display_name', '=', $testRoleName)->first(); - - // Updating - $resp = $this->get('/settings/roles/' . $role->id); - $resp->assertSee($testRoleName); - $resp->assertSee($testRoleDesc); - $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/roles/' . $role->id) . '"]', 'Save Role'); - - $resp = $this->put('/settings/roles/' . $role->id, [ - 'display_name' => $testRoleUpdateName, - 'description' => $testRoleDesc, - 'mfa_enforced' => 'true', - ]); - $resp->assertRedirect('/settings/roles'); - $this->assertDatabaseHas('roles', [ - 'display_name' => $testRoleUpdateName, - 'description' => $testRoleDesc, - 'mfa_enforced' => true, - ]); - - // Deleting - $resp = $this->get('/settings/roles/' . $role->id); - $this->withHtml($resp)->assertElementContains('a[href="' . url("/settings/roles/delete/$role->id") . '"]', 'Delete Role'); - - $resp = $this->get("/settings/roles/delete/$role->id"); - $resp->assertSee($testRoleUpdateName); - $this->withHtml($resp)->assertElementContains('form[action="' . url("/settings/roles/delete/$role->id") . '"]', 'Confirm'); - - $resp = $this->delete("/settings/roles/delete/$role->id"); - $resp->assertRedirect('/settings/roles'); - $this->get('/settings/roles')->assertSee('Role successfully deleted'); - $this->assertActivityExists(ActivityType::ROLE_DELETE); - } - - public function test_admin_role_cannot_be_removed_if_user_last_admin() - { - /** @var Role $adminRole */ - $adminRole = Role::query()->where('system_name', '=', 'admin')->first(); - $adminUser = $this->users->admin(); - $adminRole->users()->where('id', '!=', $adminUser->id)->delete(); - $this->assertEquals(1, $adminRole->users()->count()); - - $viewerRole = $this->users->viewer()->roles()->first(); - - $editUrl = '/settings/users/' . $adminUser->id; - $resp = $this->actingAs($adminUser)->put($editUrl, [ - 'name' => $adminUser->name, - 'email' => $adminUser->email, - 'roles' => [ - 'viewer' => strval($viewerRole->id), - ], - ]); - - $resp->assertRedirect($editUrl); - - $resp = $this->get($editUrl); - $resp->assertSee('This user is the only user assigned to the administrator role'); - } - - public function test_migrate_users_on_delete_works() - { - /** @var Role $roleA */ - $roleA = Role::query()->create(['display_name' => 'Delete Test A']); - /** @var Role $roleB */ - $roleB = Role::query()->create(['display_name' => 'Delete Test B']); - $this->user->attachRole($roleB); - - $this->assertCount(0, $roleA->users()->get()); - $this->assertCount(1, $roleB->users()->get()); - - $deletePage = $this->asAdmin()->get("/settings/roles/delete/$roleB->id"); - $this->withHtml($deletePage)->assertElementExists('select[name=migrate_role_id]'); - $this->asAdmin()->delete("/settings/roles/delete/$roleB->id", [ - 'migrate_role_id' => $roleA->id, - ]); - - $this->assertCount(1, $roleA->users()->get()); - $this->assertEquals($this->user->id, $roleA->users()->first()->id); - } - - public function test_delete_with_empty_migrate_option_works() - { - $role = $this->users->attachNewRole($this->user); - - $this->assertCount(1, $role->users()->get()); - - $deletePage = $this->asAdmin()->get("/settings/roles/delete/$role->id"); - $this->withHtml($deletePage)->assertElementExists('select[name=migrate_role_id]'); - $resp = $this->asAdmin()->delete("/settings/roles/delete/$role->id", [ - 'migrate_role_id' => '', - ]); - - $resp->assertRedirect('/settings/roles'); - $this->assertDatabaseMissing('roles', ['id' => $role->id]); - } - - public function test_entity_permissions_are_removed_on_delete() - { - /** @var Role $roleA */ - $roleA = Role::query()->create(['display_name' => 'Entity Permissions Delete Test']); - $page = $this->entities->page(); - - $this->permissions->setEntityPermissions($page, ['view'], [$roleA]); - - $this->assertDatabaseHas('entity_permissions', [ - 'role_id' => $roleA->id, - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), - ]); - - $this->asAdmin()->delete("/settings/roles/delete/$roleA->id"); - - $this->assertDatabaseMissing('entity_permissions', [ - 'role_id' => $roleA->id, - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), - ]); - } - - public function test_image_view_notice_shown_on_role_form() - { - /** @var Role $role */ - $role = Role::query()->first(); - $this->asAdmin()->get("/settings/roles/{$role->id}") - ->assertSee('Actual access of uploaded image files will be dependant upon system image storage option'); - } - - public function test_copy_role_button_shown() - { - /** @var Role $role */ - $role = Role::query()->first(); - $resp = $this->asAdmin()->get("/settings/roles/{$role->id}"); - $this->withHtml($resp)->assertElementContains('a[href$="/roles/new?copy_from=' . $role->id . '"]', 'Copy'); - } - - public function test_copy_from_param_on_create_prefills_with_other_role_data() - { - /** @var Role $role */ - $role = Role::query()->first(); - $resp = $this->asAdmin()->get("/settings/roles/new?copy_from={$role->id}"); - $resp->assertOk(); - $this->withHtml($resp)->assertElementExists('input[name="display_name"][value="' . ($role->display_name . ' (Copy)') . '"]'); - } - public function test_manage_user_permission() { $this->actingAs($this->user)->get('/settings/users')->assertRedirect('/'); @@ -301,12 +99,12 @@ public function test_settings_manage_permission() $resp = $this->post('/settings/features', []); $resp->assertRedirect('/settings/features'); $resp = $this->get('/settings/features'); - $resp->assertSee('Settings saved'); + $resp->assertSee('Settings successfully updated'); } public function test_restrictions_manage_all_permission() { - $page = Page::query()->get()->first(); + $page = $this->entities->page(); $this->actingAs($this->user)->get($page->getUrl())->assertDontSee('Permissions'); $this->get($page->getUrl('/permissions'))->assertRedirect('/'); @@ -322,8 +120,7 @@ public function test_restrictions_manage_all_permission() public function test_restrictions_manage_own_permission() { - /** @var Page $otherUsersPage */ - $otherUsersPage = Page::query()->first(); + $otherUsersPage = $this->entities->page(); $content = $this->entities->createChainBelongingToUser($this->user); // Set a different creator on the page we're checking to ensure @@ -798,44 +595,6 @@ public function test_page_delete_all_permission() $this->get($parent->getUrl())->assertDontSee($otherPage->name); } - public function test_public_role_visible_in_user_edit_screen() - { - /** @var User $user */ - $user = User::query()->first(); - $adminRole = Role::getSystemRole('admin'); - $publicRole = Role::getSystemRole('public'); - $resp = $this->asAdmin()->get('/settings/users/' . $user->id); - $this->withHtml($resp)->assertElementExists('[name="roles[' . $adminRole->id . ']"]') - ->assertElementExists('[name="roles[' . $publicRole->id . ']"]'); - } - - public function test_public_role_visible_in_role_listing() - { - $this->asAdmin()->get('/settings/roles') - ->assertSee('Admin') - ->assertSee('Public'); - } - - public function test_public_role_visible_in_default_role_setting() - { - $resp = $this->asAdmin()->get('/settings/registration'); - $this->withHtml($resp)->assertElementExists('[data-system-role-name="admin"]') - ->assertElementExists('[data-system-role-name="public"]'); - } - - public function test_public_role_not_deletable() - { - /** @var Role $publicRole */ - $publicRole = Role::getSystemRole('public'); - $resp = $this->asAdmin()->delete('/settings/roles/delete/' . $publicRole->id); - $resp->assertRedirect('/'); - - $this->get('/settings/roles/delete/' . $publicRole->id); - $resp = $this->delete('/settings/roles/delete/' . $publicRole->id); - $resp->assertRedirect('/settings/roles/delete/' . $publicRole->id); - $resp = $this->get('/settings/roles/delete/' . $publicRole->id); - $resp->assertSee('This role is a system role and cannot be deleted'); - } public function test_image_delete_own_permission() { @@ -874,23 +633,6 @@ public function test_image_delete_all_permission() $this->assertDatabaseMissing('images', ['id' => $image->id]); } - public function test_role_permission_removal() - { - // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a. - $page = $this->entities->page(); - $viewerRole = Role::getRole('viewer'); - $viewer = $this->users->viewer(); - $this->actingAs($viewer)->get($page->getUrl())->assertOk(); - - $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [ - 'display_name' => $viewerRole->display_name, - 'description' => $viewerRole->description, - 'permissions' => [], - ])->assertStatus(302); - - $this->actingAs($viewer)->get($page->getUrl())->assertStatus(404); - } - public function test_empty_state_actions_not_visible_without_permission() { $admin = $this->users->admin(); diff --git a/tests/Permissions/Scenarios/PermissionScenarioTestCase.php b/tests/Permissions/Scenarios/PermissionScenarioTestCase.php index 5352f468a9d..2299fe8787b 100644 --- a/tests/Permissions/Scenarios/PermissionScenarioTestCase.php +++ b/tests/Permissions/Scenarios/PermissionScenarioTestCase.php @@ -2,8 +2,8 @@ namespace Tests\Permissions\Scenarios; -use BookStack\Auth\User; use BookStack\Entities\Models\Entity; +use BookStack\Users\Models\User; use Tests\TestCase; // Cases defined in dev/docs/permission-scenario-testing.md diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index e21afdf3322..6f0e2f1d3bb 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -2,11 +2,11 @@ namespace Tests; -use BookStack\Auth\Permissions\RolePermission; -use BookStack\Auth\Role; -use BookStack\Auth\User; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; +use BookStack\Permissions\Models\RolePermission; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\View; @@ -193,4 +193,18 @@ public function test_access_hidden_content_then_login_redirects_to_intended_cont $resp->assertRedirect($book->getUrl()); $this->followRedirects($resp)->assertSee($book->name); } + + public function test_public_view_can_take_on_other_roles() + { + $this->setSettings(['app-public' => 'true']); + $newRole = $this->users->attachNewRole(User::getDefault(), []); + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $this->permissions->addEntityPermission($page, ['view', 'update'], $newRole); + + $resp = $this->get($page->getUrl()); + $resp->assertOk(); + + $this->withHtml($resp)->assertLinkExists($page->getUrl('/edit')); + } } diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index 4330598baf6..a19e1b90157 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -2,9 +2,9 @@ namespace Tests\References; +use BookStack\App\Model; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\TrashCan; -use BookStack\Model; use BookStack\References\Reference; use Tests\TestCase; diff --git a/tests/Settings/RegenerateReferencesTest.php b/tests/Settings/RegenerateReferencesTest.php index 239f50e765f..25832b03e58 100644 --- a/tests/Settings/RegenerateReferencesTest.php +++ b/tests/Settings/RegenerateReferencesTest.php @@ -2,7 +2,7 @@ namespace Tests\Settings; -use BookStack\Actions\ActivityType; +use BookStack\Activity\ActivityType; use BookStack\References\ReferenceStore; use Tests\TestCase; diff --git a/tests/Settings/SettingsTest.php b/tests/Settings/SettingsTest.php index fb952585a20..9d45706e77b 100644 --- a/tests/Settings/SettingsTest.php +++ b/tests/Settings/SettingsTest.php @@ -6,6 +6,11 @@ class SettingsTest extends TestCase { + public function test_admin_can_see_settings() + { + $this->asAdmin()->get('/settings/features')->assertSee('Settings'); + } + public function test_settings_endpoint_redirects_to_settings_view() { $resp = $this->asAdmin()->get('/settings'); diff --git a/tests/TestCase.php b/tests/TestCase.php index abee1d3b342..322ab037032 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -213,18 +213,14 @@ protected function assertNotPermissionError($response) */ private function isPermissionError($response): bool { + if ($response->status() === 403 && $response instanceof JsonResponse) { + $errMessage = $response->getData(true)['error']['message'] ?? ''; + return $errMessage === 'You do not have permission to perform the requested action.'; + } + return $response->status() === 302 - && ( - ( - $response->headers->get('Location') === url('/') - && strpos(session()->pull('error', ''), 'You do not have permission to access') === 0 - ) - || - ( - $response instanceof JsonResponse && - $response->json(['error' => 'You do not have permission to perform the requested action.']) - ) - ); + && $response->headers->get('Location') === url('/') + && str_starts_with(session()->pull('error', ''), 'You do not have permission to access'); } /** diff --git a/tests/ThemeTest.php b/tests/ThemeTest.php index 03ae7b307d4..6976f23847c 100644 --- a/tests/ThemeTest.php +++ b/tests/ThemeTest.php @@ -2,15 +2,15 @@ namespace Tests; -use BookStack\Actions\ActivityType; -use BookStack\Actions\DispatchWebhookJob; -use BookStack\Actions\Webhook; -use BookStack\Auth\User; +use BookStack\Activity\ActivityType; +use BookStack\Activity\DispatchWebhookJob; +use BookStack\Activity\Models\Webhook; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Page; use BookStack\Entities\Tools\PageContent; 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; @@ -23,8 +23,8 @@ class ThemeTest extends TestCase { - protected $themeFolderName; - protected $themeFolderPath; + protected string $themeFolderName; + protected string $themeFolderPath; public function test_translation_text_can_be_overridden_via_theme() { diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php index 9966a4fb151..2de32c1b8c8 100644 --- a/tests/Unit/ConfigTest.php +++ b/tests/Unit/ConfigTest.php @@ -3,6 +3,8 @@ namespace Tests\Unit; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; use Tests\TestCase; /** @@ -96,11 +98,68 @@ public function test_snappy_paper_size_options_are_limited() $this->checkEnvConfigResult('EXPORT_PAGE_SIZE', 'a4', 'snappy.pdf.options.page-size', 'A4'); } - public function test_sendmail_command_is_configurage() + public function test_sendmail_command_is_configurable() { $this->checkEnvConfigResult('MAIL_SENDMAIL_COMMAND', '/var/sendmail -o', 'mail.mailers.sendmail.path', '/var/sendmail -o'); } + public function test_mail_disable_ssl_verification_alters_mailer() + { + $getStreamOptions = function (): array { + /** @var EsmtpTransport $transport */ + $transport = Mail::mailer('smtp')->getSymfonyTransport(); + return $transport->getStream()->getStreamOptions(); + }; + + $this->assertEmpty($getStreamOptions()); + + + $this->runWithEnv('MAIL_VERIFY_SSL', 'false', function () use ($getStreamOptions) { + $options = $getStreamOptions(); + $this->assertArrayHasKey('ssl', $options); + $this->assertFalse($options['ssl']['verify_peer']); + $this->assertFalse($options['ssl']['verify_peer_name']); + }); + } + + public function test_non_null_mail_encryption_options_enforce_smtp_scheme() + { + $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'tls', 'mail.mailers.smtp.tls_required', true); + $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'ssl', 'mail.mailers.smtp.tls_required', true); + $this->checkEnvConfigResult('MAIL_ENCRYPTION', 'null', 'mail.mailers.smtp.tls_required', false); + } + + public function test_smtp_scheme_and_certain_port_forces_tls_usage() + { + $isMailTlsRequired = function () { + /** @var \BookStack\App\Mail\EsmtpTransport $transport */ + $transport = Mail::mailer('smtp')->getSymfonyTransport(); + Mail::purge('smtp'); + return $transport->getTlsRequirement(); + }; + + config()->set([ + 'mail.mailers.smtp.tls_required' => null, + 'mail.mailers.smtp.port' => 587, + ]); + + $this->assertFalse($isMailTlsRequired()); + + config()->set([ + 'mail.mailers.smtp.tls_required' => 'tls', + 'mail.mailers.smtp.port' => 587, + ]); + + $this->assertTrue($isMailTlsRequired()); + + config()->set([ + 'mail.mailers.smtp.tls_required' => null, + 'mail.mailers.smtp.port' => 465, + ]); + + $this->assertTrue($isMailTlsRequired()); + } + /** * Set an environment variable of the given name and value * then check the given config key to see if it matches the given result. diff --git a/tests/Unit/IpFormatterTest.php b/tests/Unit/IpFormatterTest.php index 928b1ab1009..14878257570 100644 --- a/tests/Unit/IpFormatterTest.php +++ b/tests/Unit/IpFormatterTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit; -use BookStack\Actions\IpFormatter; +use BookStack\Activity\Tools\IpFormatter; use Tests\TestCase; class IpFormatterTest extends TestCase diff --git a/tests/Unit/OidcIdTokenTest.php b/tests/Unit/OidcIdTokenTest.php index ad91eecd8b2..6302f84c75f 100644 --- a/tests/Unit/OidcIdTokenTest.php +++ b/tests/Unit/OidcIdTokenTest.php @@ -2,8 +2,8 @@ namespace Tests\Unit; -use BookStack\Auth\Access\Oidc\OidcIdToken; -use BookStack\Auth\Access\Oidc\OidcInvalidTokenException; +use BookStack\Access\Oidc\OidcIdToken; +use BookStack\Access\Oidc\OidcInvalidTokenException; use Tests\Helpers\OidcJwtHelper; use Tests\TestCase; diff --git a/tests/Uploads/AvatarTest.php b/tests/Uploads/AvatarTest.php index 57f28db4292..363c1fa9542 100644 --- a/tests/Uploads/AvatarTest.php +++ b/tests/Uploads/AvatarTest.php @@ -2,9 +2,10 @@ namespace Tests\Uploads; -use BookStack\Auth\User; use BookStack\Exceptions\HttpFetchException; use BookStack\Uploads\HttpFetcher; +use BookStack\Uploads\UserAvatars; +use BookStack\Users\Models\User; use Tests\TestCase; class AvatarTest extends TestCase @@ -110,4 +111,28 @@ public function test_no_failure_but_error_logged_on_failed_avatar_fetch() $this->createUserRequest($user); $this->assertTrue($logger->hasError('Failed to save user avatar image')); } + + public function test_exception_message_on_failed_fetch() + { + // set wrong url + config()->set([ + 'services.disable_services' => false, + 'services.avatar_url' => 'http_malformed_url/${email}/${hash}/${size}', + ]); + + $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(); + + $avatar->fetchAndAssignToUser($user); + + $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); + } } diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 53040ea086b..a9684eef72a 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -92,6 +92,52 @@ public function test_image_edit() ]); } + public function test_image_file_update() + { + $page = $this->entities->page(); + $this->asEditor(); + + $imgDetails = $this->files->uploadGalleryImageToPage($this, $page); + $relPath = $imgDetails['path']; + + $newUpload = $this->files->uploadedImage('updated-image.png', 'compressed.png'); + $this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($relPath)); + + $imageId = $imgDetails['response']->id; + $image = Image::findOrFail($imageId); + $image->updated_at = now()->subMonth(); + $image->save(); + + $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload]) + ->assertOk(); + + $this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($relPath)); + + $image->refresh(); + $this->assertTrue($image->updated_at->gt(now()->subMinute())); + + $this->files->deleteAtRelativePath($relPath); + } + + public function test_image_file_update_does_not_allow_change_in_image_extension() + { + $page = $this->entities->page(); + $this->asEditor(); + + $imgDetails = $this->files->uploadGalleryImageToPage($this, $page); + $relPath = $imgDetails['path']; + $newUpload = $this->files->uploadedImage('updated-image.jpg', 'compressed.png'); + + $imageId = $imgDetails['response']->id; + $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload]) + ->assertJson([ + "message" => "Image file replacements must be of the same type", + "status" => "error", + ]); + + $this->files->deleteAtRelativePath($relPath); + } + public function test_gallery_get_list_format() { $this->asEditor(); @@ -163,7 +209,8 @@ public function test_php_files_cannot_be_uploaded() $file = $this->files->imageFromBase64File('bad-php.base64', $fileName); $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []); - $upload->assertStatus(302); + $upload->assertStatus(500); + $this->assertStringContainsString('The file must have a valid & supported image extension', $upload->json('message')); $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded php file was uploaded but should have been stopped'); @@ -185,7 +232,8 @@ public function test_php_like_files_cannot_be_uploaded() $file = $this->files->imageFromBase64File('bad-phtml.base64', $fileName); $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []); - $upload->assertStatus(302); + $upload->assertStatus(500); + $this->assertStringContainsString('The file must have a valid & supported image extension', $upload->json('message')); $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded php file was uploaded but should have been stopped'); } @@ -491,15 +539,15 @@ public function test_image_manager_delete_button_only_shows_with_permission() $image = Image::first(); $resp = $this->get("/images/edit/{$image->id}"); - $this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]'); + $this->withHtml($resp)->assertElementExists('button#image-manager-delete'); $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}"); - $this->withHtml($resp)->assertElementNotExists('button#image-manager-delete[title="Delete"]'); + $this->withHtml($resp)->assertElementNotExists('button#image-manager-delete'); $this->permissions->grantUserRolePermissions($viewer, ['image-delete-all']); $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}"); - $this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]'); + $this->withHtml($resp)->assertElementExists('button#image-manager-delete'); $this->files->deleteAtRelativePath($relPath); } diff --git a/tests/User/RoleManagementTest.php b/tests/User/RoleManagementTest.php new file mode 100644 index 00000000000..5722a0b08c8 --- /dev/null +++ b/tests/User/RoleManagementTest.php @@ -0,0 +1,289 @@ +id; + + $this->asAdmin()->get($deletePageUrl); + $this->delete($deletePageUrl)->assertRedirect($deletePageUrl); + $this->get($deletePageUrl)->assertSee('cannot be deleted'); + } + + public function test_role_cannot_be_deleted_if_default() + { + $newRole = $this->users->createRole(); + $this->setSettings(['registration-role' => $newRole->id]); + + $deletePageUrl = '/settings/roles/delete/' . $newRole->id; + $this->asAdmin()->get($deletePageUrl); + $this->delete($deletePageUrl)->assertRedirect($deletePageUrl); + $this->get($deletePageUrl)->assertSee('cannot be deleted'); + } + + public function test_role_create_update_delete_flow() + { + $testRoleName = 'Test Role'; + $testRoleDesc = 'a little test description'; + $testRoleUpdateName = 'An Super Updated role'; + + // Creation + $resp = $this->asAdmin()->get('/settings/features'); + $this->withHtml($resp)->assertElementContains('a[href="' . url('/settings/roles') . '"]', 'Roles'); + + $resp = $this->get('/settings/roles'); + $this->withHtml($resp)->assertElementContains('a[href="' . url('/settings/roles/new') . '"]', 'Create New Role'); + + $resp = $this->get('/settings/roles/new'); + $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/roles/new') . '"]', 'Save Role'); + + $resp = $this->post('/settings/roles/new', [ + 'display_name' => $testRoleName, + 'description' => $testRoleDesc, + ]); + $resp->assertRedirect('/settings/roles'); + + $resp = $this->get('/settings/roles'); + $resp->assertSee($testRoleName); + $resp->assertSee($testRoleDesc); + $this->assertDatabaseHas('roles', [ + 'display_name' => $testRoleName, + 'description' => $testRoleDesc, + 'mfa_enforced' => false, + ]); + + /** @var Role $role */ + $role = Role::query()->where('display_name', '=', $testRoleName)->first(); + + // Updating + $resp = $this->get('/settings/roles/' . $role->id); + $resp->assertSee($testRoleName); + $resp->assertSee($testRoleDesc); + $this->withHtml($resp)->assertElementContains('form[action="' . url('/settings/roles/' . $role->id) . '"]', 'Save Role'); + + $resp = $this->put('/settings/roles/' . $role->id, [ + 'display_name' => $testRoleUpdateName, + 'description' => $testRoleDesc, + 'mfa_enforced' => 'true', + ]); + $resp->assertRedirect('/settings/roles'); + $this->assertDatabaseHas('roles', [ + 'display_name' => $testRoleUpdateName, + 'description' => $testRoleDesc, + 'mfa_enforced' => true, + ]); + + // Deleting + $resp = $this->get('/settings/roles/' . $role->id); + $this->withHtml($resp)->assertElementContains('a[href="' . url("/settings/roles/delete/$role->id") . '"]', 'Delete Role'); + + $resp = $this->get("/settings/roles/delete/$role->id"); + $resp->assertSee($testRoleUpdateName); + $this->withHtml($resp)->assertElementContains('form[action="' . url("/settings/roles/delete/$role->id") . '"]', 'Confirm'); + + $resp = $this->delete("/settings/roles/delete/$role->id"); + $resp->assertRedirect('/settings/roles'); + $this->get('/settings/roles')->assertSee('Role successfully deleted'); + $this->assertActivityExists(ActivityType::ROLE_DELETE); + } + + public function test_admin_role_cannot_be_removed_if_user_last_admin() + { + /** @var Role $adminRole */ + $adminRole = Role::query()->where('system_name', '=', 'admin')->first(); + $adminUser = $this->users->admin(); + $adminRole->users()->where('id', '!=', $adminUser->id)->delete(); + $this->assertEquals(1, $adminRole->users()->count()); + + $viewerRole = $this->users->viewer()->roles()->first(); + + $editUrl = '/settings/users/' . $adminUser->id; + $resp = $this->actingAs($adminUser)->put($editUrl, [ + 'name' => $adminUser->name, + 'email' => $adminUser->email, + 'roles' => [ + 'viewer' => strval($viewerRole->id), + ], + ]); + + $resp->assertRedirect($editUrl); + + $resp = $this->get($editUrl); + $resp->assertSee('This user is the only user assigned to the administrator role'); + } + + public function test_migrate_users_on_delete_works() + { + $roleA = $this->users->createRole(); + $roleB = $this->users->createRole(); + $user = $this->users->viewer(); + $user->attachRole($roleB); + + $this->assertCount(0, $roleA->users()->get()); + $this->assertCount(1, $roleB->users()->get()); + + $deletePage = $this->asAdmin()->get("/settings/roles/delete/$roleB->id"); + $this->withHtml($deletePage)->assertElementExists('select[name=migrate_role_id]'); + $this->asAdmin()->delete("/settings/roles/delete/$roleB->id", [ + 'migrate_role_id' => $roleA->id, + ]); + + $this->assertCount(1, $roleA->users()->get()); + $this->assertEquals($user->id, $roleA->users()->first()->id); + } + + public function test_delete_with_empty_migrate_option_works() + { + $role = $this->users->attachNewRole($this->users->viewer()); + + $this->assertCount(1, $role->users()->get()); + + $deletePage = $this->asAdmin()->get("/settings/roles/delete/$role->id"); + $this->withHtml($deletePage)->assertElementExists('select[name=migrate_role_id]'); + $resp = $this->asAdmin()->delete("/settings/roles/delete/$role->id", [ + 'migrate_role_id' => '', + ]); + + $resp->assertRedirect('/settings/roles'); + $this->assertDatabaseMissing('roles', ['id' => $role->id]); + } + + public function test_entity_permissions_are_removed_on_delete() + { + /** @var Role $roleA */ + $roleA = Role::query()->create(['display_name' => 'Entity Permissions Delete Test']); + $page = $this->entities->page(); + + $this->permissions->setEntityPermissions($page, ['view'], [$roleA]); + + $this->assertDatabaseHas('entity_permissions', [ + 'role_id' => $roleA->id, + 'entity_id' => $page->id, + 'entity_type' => $page->getMorphClass(), + ]); + + $this->asAdmin()->delete("/settings/roles/delete/$roleA->id"); + + $this->assertDatabaseMissing('entity_permissions', [ + 'role_id' => $roleA->id, + 'entity_id' => $page->id, + 'entity_type' => $page->getMorphClass(), + ]); + } + + public function test_image_view_notice_shown_on_role_form() + { + /** @var Role $role */ + $role = Role::query()->first(); + $this->asAdmin()->get("/settings/roles/{$role->id}") + ->assertSee('Actual access of uploaded image files will be dependant upon system image storage option'); + } + + public function test_copy_role_button_shown() + { + /** @var Role $role */ + $role = Role::query()->first(); + $resp = $this->asAdmin()->get("/settings/roles/{$role->id}"); + $this->withHtml($resp)->assertElementContains('a[href$="/roles/new?copy_from=' . $role->id . '"]', 'Copy'); + } + + public function test_copy_from_param_on_create_prefills_with_other_role_data() + { + /** @var Role $role */ + $role = Role::query()->first(); + $resp = $this->asAdmin()->get("/settings/roles/new?copy_from={$role->id}"); + $resp->assertOk(); + $this->withHtml($resp)->assertElementExists('input[name="display_name"][value="' . ($role->display_name . ' (Copy)') . '"]'); + } + + public function test_public_role_visible_in_user_edit_screen() + { + /** @var User $user */ + $user = User::query()->first(); + $adminRole = Role::getSystemRole('admin'); + $publicRole = Role::getSystemRole('public'); + $resp = $this->asAdmin()->get('/settings/users/' . $user->id); + $this->withHtml($resp)->assertElementExists('[name="roles[' . $adminRole->id . ']"]') + ->assertElementExists('[name="roles[' . $publicRole->id . ']"]'); + } + + public function test_public_role_visible_in_role_listing() + { + $this->asAdmin()->get('/settings/roles') + ->assertSee('Admin') + ->assertSee('Public'); + } + + public function test_public_role_visible_in_default_role_setting() + { + $resp = $this->asAdmin()->get('/settings/registration'); + $this->withHtml($resp)->assertElementExists('[data-system-role-name="admin"]') + ->assertElementExists('[data-system-role-name="public"]'); + } + + public function test_public_role_not_deletable() + { + /** @var Role $publicRole */ + $publicRole = Role::getSystemRole('public'); + $resp = $this->asAdmin()->delete('/settings/roles/delete/' . $publicRole->id); + $resp->assertRedirect('/'); + + $this->get('/settings/roles/delete/' . $publicRole->id); + $resp = $this->delete('/settings/roles/delete/' . $publicRole->id); + $resp->assertRedirect('/settings/roles/delete/' . $publicRole->id); + $resp = $this->get('/settings/roles/delete/' . $publicRole->id); + $resp->assertSee('This role is a system role and cannot be deleted'); + } + + public function test_role_permission_removal() + { + // To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a. + $page = $this->entities->page(); + $viewerRole = Role::getRole('viewer'); + $viewer = $this->users->viewer(); + $this->actingAs($viewer)->get($page->getUrl())->assertOk(); + + $this->asAdmin()->put('/settings/roles/' . $viewerRole->id, [ + 'display_name' => $viewerRole->display_name, + 'description' => $viewerRole->description, + 'permissions' => [], + ])->assertStatus(302); + + $this->actingAs($viewer)->get($page->getUrl())->assertStatus(404); + } + + public function test_index_listing_sorting() + { + $this->asAdmin(); + $role = $this->users->createRole(); + $role->display_name = 'zz test role'; + $role->created_at = now()->addDays(1); + $role->save(); + + $runTest = function (string $order, string $direction, bool $expectFirstResult) use ($role) { + setting()->putForCurrentUser('roles_sort', $order); + setting()->putForCurrentUser('roles_sort_order', $direction); + $html = $this->withHtml($this->get('/settings/roles')); + $selector = ".item-list-row:first-child a[href$=\"/roles/{$role->id}\"]"; + if ($expectFirstResult) { + $html->assertElementExists($selector); + } else { + $html->assertElementNotExists($selector); + } + }; + + $runTest('name', 'asc', false); + $runTest('name', 'desc', true); + $runTest('created_at', 'desc', true); + $runTest('created_at', 'asc', false); + } +} diff --git a/tests/User/UserApiTokenTest.php b/tests/User/UserApiTokenTest.php index 93070b71218..75de49aed9a 100644 --- a/tests/User/UserApiTokenTest.php +++ b/tests/User/UserApiTokenTest.php @@ -2,7 +2,7 @@ namespace Tests\User; -use BookStack\Actions\ActivityType; +use BookStack\Activity\ActivityType; use BookStack\Api\ApiToken; use Carbon\Carbon; use Tests\TestCase; diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index 7ccc5b773a7..df60bede6e1 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -2,11 +2,11 @@ namespace Tests\User; -use BookStack\Actions\ActivityType; -use BookStack\Auth\Access\UserInviteService; -use BookStack\Auth\Role; -use BookStack\Auth\User; +use BookStack\Access\UserInviteService; +use BookStack\Activity\ActivityType; use BookStack\Uploads\Image; +use BookStack\Users\Models\Role; +use BookStack\Users\Models\User; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Mockery\MockInterface; diff --git a/tests/User/UserProfileTest.php b/tests/User/UserProfileTest.php index c507e8fa63a..4bfb3c87822 100644 --- a/tests/User/UserProfileTest.php +++ b/tests/User/UserProfileTest.php @@ -3,8 +3,8 @@ namespace Tests\User; use Activity; -use BookStack\Actions\ActivityType; -use BookStack\Auth\User; +use BookStack\Activity\ActivityType; +use BookStack\Users\Models\User; use Tests\TestCase; class UserProfileTest extends TestCase diff --git a/tests/User/UserSearchTest.php b/tests/User/UserSearchTest.php index 1b3ca8a3514..1387311ce75 100644 --- a/tests/User/UserSearchTest.php +++ b/tests/User/UserSearchTest.php @@ -2,7 +2,7 @@ namespace Tests\User; -use BookStack\Auth\User; +use BookStack\Users\Models\User; use Tests\TestCase; class UserSearchTest extends TestCase diff --git a/version b/version index 370aac099a8..10f06c6bd76 100644 --- a/version +++ b/version @@ -1 +1 @@ -v23.02.3 +v23.06.2