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