Merge branch 'development' into Zhey-on/feature/csp-image-css-controls-6033

This commit is contained in:
Dan Brown
2026-05-17 17:57:54 +01:00
363 changed files with 6163 additions and 2034 deletions

View File

@@ -117,7 +117,7 @@ class CommentMentionTest extends TestCase
$page = $this->entities->page();
$this->permissions->disableEntityInheritedPermissions($page);
$this->permissions->addEntityPermission($page, ['view'], $userA->roles()->first());
$this->permissions->setEntityPermissionsForRole($page, ['view'], $userA->roles()->first());
$this->asAdmin()->post("/comment/{$page->id}", [
'html' => '<p>Hello <a data-mention-user-id="' . $userA->id . '"></a> and <a data-mention-user-id="' . $userB->id . '"></a></p>'

View File

@@ -39,7 +39,7 @@ class ContentPermissionsApiTest extends TestCase
$page = $this->entities->page();
$owner = $this->users->newUser();
$role = $this->users->createRole();
$this->permissions->addEntityPermission($page, ['view', 'delete'], $role);
$this->permissions->setEntityPermissionsForRole($page, ['view', 'delete'], $role);
$this->permissions->changeEntityOwner($page, $owner);
$this->permissions->setFallbackPermissions($page, ['update', 'create']);
@@ -209,7 +209,7 @@ class ContentPermissionsApiTest extends TestCase
public function test_update_can_clear_roles_permissions()
{
$page = $this->entities->page();
$this->permissions->addEntityPermission($page, ['view'], $this->users->createRole());
$this->permissions->setEntityPermissionsForRole($page, ['view'], $this->users->createRole());
$page->owned_by = null;
$page->save();

View File

@@ -154,6 +154,34 @@ class ImageGalleryApiTest extends TestCase
$resp->assertStatus(404);
}
public function test_create_requires_update_permission_for_the_target_page()
{
$editor = $this->users->editor();
$this->actingAsForApi($editor);
$makeRequest = function (int $uploadedTo) {
return $this->call('POST', $this->baseEndpoint, [
'type' => 'gallery',
'uploaded_to' => $uploadedTo,
'name' => 'My awesome image!',
], [], [
'image' => $this->files->uploadedImage('my-cool-image.png'),
]);
};
$page = $this->entities->page();
$this->permissions->disableEntityInheritedPermissions($page);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first());
$resp = $makeRequest($page->id);
$resp->assertStatus(403);
$this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first());
$resp = $makeRequest($page->id);
$resp->assertStatus(200);
}
public function test_create_has_restricted_types()
{
$this->actingAsApiEditor();

View File

@@ -106,6 +106,7 @@ class SearchApiTest extends TestCase
$this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]);
$resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue');
$resp->assertOk();
$resp->assertJsonPath('data.0.id', $page->id);
$resp->assertJsonPath('data.0.book.name', $book->name);
$resp->assertJsonMissingPath('data.0.chapter');

108
tests/Api/TagsApiTest.php Normal file
View File

@@ -0,0 +1,108 @@
<?php
namespace Tests\Api;
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Tests\TestCase;
class TagsApiTest extends TestCase
{
use TestsApi;
public function test_list_names_provides_rolled_up_tag_info(): void
{
$tagInfo = ['name' => 'MyGreatApiTag', 'value' => 'cat'];
$pagesToTag = Page::query()->take(10)->get();
$booksToTag = Book::query()->take(3)->get();
$chaptersToTag = Chapter::query()->take(5)->get();
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag($tagInfo)));
$booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag($tagInfo)));
$chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag($tagInfo)));
$resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag');
$resp->assertStatus(200);
$resp->assertJson([
'data' => [
[
'name' => 'MyGreatApiTag',
'values' => 1,
'usages' => 18,
'page_count' => 10,
'book_count' => 3,
'chapter_count' => 5,
'shelf_count' => 0,
]
],
'total' => 1,
]);
}
public function test_list_names_is_limited_by_permission_visibility(): void
{
$pagesToTag = Page::query()->take(10)->get();
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id])));
$this->permissions->disableEntityInheritedPermissions($pagesToTag[3]);
$this->permissions->disableEntityInheritedPermissions($pagesToTag[6]);
$resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag');
$resp->assertStatus(200);
$resp->assertJson([
'data' => [
[
'name' => 'MyGreatApiTag',
'values' => 8,
'usages' => 8,
'page_count' => 8,
'book_count' => 0,
'chapter_count' => 0,
'shelf_count' => 0,
]
],
'total' => 1,
]);
}
public function test_list_values_returns_values_for_set_tag()
{
$pagesToTag = Page::query()->take(10)->get();
$booksToTag = Book::query()->take(3)->get();
$chaptersToTag = Chapter::query()->take(5)->get();
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-page' . $page->id])));
$booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id])));
$chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id])));
$resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyValueApiTag');
$resp->assertStatus(200);
$resp->assertJson(['total' => 18]);
$resp->assertJsonFragment([
[
'name' => 'MyValueApiTag',
'value' => 'tag-page' . $pagesToTag[0]->id,
'usages' => 1,
'page_count' => 1,
'book_count' => 0,
'chapter_count' => 0,
'shelf_count' => 0,
]
]);
}
public function test_list_values_is_limited_by_permission_visibility(): void
{
$pagesToTag = Page::query()->take(10)->get();
$pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id])));
$this->permissions->disableEntityInheritedPermissions($pagesToTag[3]);
$this->permissions->disableEntityInheritedPermissions($pagesToTag[6]);
$resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyGreatApiTag');
$resp->assertStatus(200);
$resp->assertJson(['total' => 8]);
$resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]);
}
}

View File

@@ -2,6 +2,7 @@
namespace Tests\Commands;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use Tests\TestCase;
@@ -61,4 +62,21 @@ class CopyShelfPermissionsCommandTest extends TestCase
'view' => true, 'update' => true, 'create' => false, 'delete' => false,
]);
}
public function test_copy_shelf_permissions_command_using_slug_without_interaction()
{
$shelf = $this->entities->shelfHasBooks();
$editorRole = $this->users->editor()->roles()->first();
/** @var Book $child */
$child = $shelf->books()->first();
$child->shelves()->where('id', '!=', $shelf->id)->delete();
$this->assertFalse($child->hasPermissions());
$this->permissions->setEntityPermissions($shelf, ['view', 'update'], [$editorRole]);
$this->artisan('bookstack:copy-shelf-permissions --all --no-interaction');
$child->refresh();
$this->assertTrue($child->hasPermissions(), 'Child book should now be restricted');
}
}

View File

@@ -96,18 +96,44 @@ class InstallModuleCommandTest extends TestCase
});
}
public function test_remote_module_install_does_not_follow_redirects_to_different_origin()
public function test_remote_module_install_prompts_on_following_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(301, ['Location' => 'https://a.example.com:8080/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')
->expectsOutput('The download URL is redirecting to a different site: http://example.com')
->expectsConfirmation('Do you trust downloading the module from this site?', 'yes')
->expectsOutput('The download URL is redirecting to a different site: https://a.example.com:8080')
->expectsConfirmation('Do you trust downloading the module from this site?', 'yes')
->assertExitCode(0);
$this->assertEquals(3, $http->requestCount());
$this->assertEquals('https', $http->requestAt(0)->getUri()->getScheme());
$this->assertEquals('http', $http->requestAt(1)->getUri()->getScheme());
$this->assertEquals('a.example.com', $http->requestAt(2)->getUri()->getHost());
});
}
public function test_remote_module_install_redirect_origin_prompt_rejection()
{
$this->usingThemeFolder(function () {
$http = $this->mockHttpClient([
new Response(302, ['Location' => 'http://example.com/a-test-module.zip']),
new Response(301, ['Location' => 'https://a.example.com:8080/a-test-module.zip']),
]);
$this->artisan('bookstack:install-module', ['location' => 'https://example.com/test-module.zip'])
->expectsConfirmation('Are you sure you trust this source?', 'yes')
->expectsOutput('The download URL is redirecting to a different site: http://example.com')
->expectsConfirmation('Do you trust downloading the module from this site?', 'no')
->assertExitCode(1);
$this->assertEquals(1, $http->requestCount());
@@ -115,6 +141,26 @@ class InstallModuleCommandTest extends TestCase
});
}
public function test_remote_module_install_has_redirect_limit()
{
$this->usingThemeFolder(function () {
$http = $this->mockHttpClient([
new Response(302, ['Location' => 'https://example.com/a-test-module.zip']),
new Response(302, ['Location' => 'https://example.com/b-test-module.zip']),
new Response(302, ['Location' => 'https://example.com/c-test-module.zip']),
new Response(302, ['Location' => 'https://example.com/d-test-module.zip']),
]);
$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')
->assertExitCode(1);
$this->assertEquals(4, $http->requestCount());
$this->assertEquals('/c-test-module.zip', $http->requestAt(3)->getUri()->getPath());
});
}
public function test_remote_module_install_download_failures_are_announced_to_user()
{
$this->usingThemeFolder(function () {
@@ -175,6 +221,52 @@ class InstallModuleCommandTest extends TestCase
->assertExitCode(1);
}
public function test_module_zip_when_files_in_nested_directory()
{
$this->usingThemeFolder(function ($themeFolder) {
$zip = new ZipArchive();
$zipFile = tempnam(sys_get_temp_dir(), 'bs-test-module');
$zip->open($zipFile, ZipArchive::CREATE);
$zip->addEmptyDir('mod');
$zip->addFromString('mod/bookstack-module.json', json_encode($metadata ?? [
'name' => 'Test Module',
'description' => 'A test module for BookStack',
'version' => '1.0.0',
]));
$zip->addFromString('mod/functions.php', '<?php $a = "cat";');
$zip->addEmptyDir('mod/a');
$zip->addFromString('mod/a/cat.txt', 'Meow');
$zip->close();
$this->artisan('bookstack:install-module', ['location' => $zipFile])
->expectsConfirmation('Are you sure you want to install this module?', 'yes')
->assertExitCode(0);
$modulePath = glob(theme_path('modules/*'), GLOB_ONLYDIR)[0];
$this->assertFileExists($modulePath . '/a/cat.txt');
$contents = file_get_contents($modulePath . '/a/cat.txt');
$this->assertEquals('Meow', $contents);
});
}
public function test_module_install_negates_zip_slip()
{
$this->usingThemeFolder(function () {
$zip = $this->getModuleZipPath(null, [
'../parent.txt' => str_repeat('dog', 10)
]);
$expectedInstallPath = theme_path('modules/test-module');
$this->artisan('bookstack:install-module', ['location' => $zip])
->expectsConfirmation('Are you sure you want to install this module?', 'yes')
->expectsOutput("ERROR: Failed to install module with error: Failed to load extract files from module ZIP with error: Bad file path found in module ZIP file: ../parent.txt")
->assertExitCode(1);
$this->assertDirectoryDoesNotExist($expectedInstallPath);
});
}
public function test_local_module_install_without_active_theme_can_setup_theme_folder()
{
$zip = $this->getModuleZipPath();

View File

@@ -16,7 +16,7 @@ class RegeneratePermissionsCommandTest extends TestCase
$page = $this->entities->page();
$editor = $this->users->editor();
$role = $editor->roles()->first();
$this->permissions->addEntityPermission($page, ['view'], $role);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $role);
JointPermission::query()->truncate();
$this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]);

View File

@@ -27,7 +27,7 @@ class DebugViewTest extends TestCase
$resp->assertSeeText('BookStack Version: ' . trim(file_get_contents(base_path('version'))));
// Dynamic help links
$this->withHtml($resp)->assertElementExists('a[href*="q=' . urlencode('BookStack An error occurred during testing') . '"]');
$this->withHtml($resp)->assertElementExists('a[href*="?q=is%3Aissue+' . urlencode('An error occurred during testing') . '"]');
$this->withHtml($resp)->assertElementExists('a[href*="?q=' . urlencode('An error occurred during testing') . '"]');
}
public function test_debug_view_only_shows_when_debug_mode_is_enabled()

View File

@@ -256,8 +256,8 @@ class BookTest extends TestCase
{
$book = $this->entities->book();
$input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" target="_blank" data-a="b">a</a><section>Hello</section></p>';
$expected = '<p>Content<a href="#cat" target="_blank">a</a></p>';
$input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" target="_blank" data-a="b">a</a><section>Hello</section><code id="abc">code</code></p>';
$expected = '<p>Content<a href="#cat" target="_blank">a</a><code>code</code></p>';
$this->asEditor()->put($book->getUrl(), [
'name' => $book->name,

View File

@@ -370,7 +370,7 @@ class PageContentTest extends TestCase
public function test_base64_images_within_html_blanked_if_not_supported_extension_for_extract()
{
// Relevant to https://github.com/BookStackApp/BookStack/issues/3010 and other cases
// Relevant to https://codeberg.org/bookstack/bookstack/issues/3010 and other cases
$extensions = [
'jiff', 'pngr', 'png ', ' png', '.png', 'png.', 'p.ng', ',png',
'data:image/png', ',data:image/png',

View File

@@ -4,6 +4,8 @@ namespace Tests\Entity;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Permissions\Permission;
use Tests\TestCase;
class PageRevisionTest extends TestCase
@@ -257,6 +259,33 @@ class PageRevisionTest extends TestCase
$revisionView->assertDontSee('dontwantthishere');
}
public function test_access_to_revision_operation_requires_revision_view_all_permission()
{
$editor = $this->users->editor();
$this->actingAs($editor);
$page = $this->entities->page();
$this->createRevisions($page, 3);
/** @var PageRevision $revision */
$revision = $page->revisions()->orderBy('id', 'desc')->first();
$this->get($page->getUrl())->assertSee($page->getUrl('/revisions'), false);
$this->get($page->getUrl('/revisions'))->assertOk();
$this->get($revision->getUrl())->assertOk();
$this->get($revision->getUrl('/changes'))->assertOk();
$this->put($revision->getUrl('/restore'))->assertRedirect($page->getUrl());
$this->delete($revision->getUrl('/delete'))->assertRedirect($page->getUrl('/revisions'));
$this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]);
$this->get($page->getUrl())->assertDontSee($page->getUrl('/revisions'), false);
$this->assertPermissionError($this->get($page->getUrl('/revisions')));
$this->assertPermissionError($this->get($revision->getUrl()));
$this->assertPermissionError($this->get($revision->getUrl('/changes')));
$this->assertPermissionError($this->put($revision->getUrl('/restore')));
$this->assertPermissionError($this->delete($revision->getUrl('/delete')));
}
public function test_revision_restore_action_only_visible_with_permission()
{
$page = $this->entities->page();

View File

@@ -2,7 +2,6 @@
namespace Tests;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Support\Facades\Log;
class ErrorTest extends TestCase
@@ -44,7 +43,7 @@ class ErrorTest extends TestCase
$this->actingAs($editor)->get($page->getUrl())->assertOk();
$this->permissions->disableEntityInheritedPermissions($book);
$this->permissions->addEntityPermission($page, ['view'], $editor->roles()->first());
$this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first());
$resp = $this->actingAs($editor)->get($book->getUrl());
$resp->assertNotFound();

View File

@@ -5,6 +5,7 @@ namespace Tests\Exports;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -229,6 +230,20 @@ class HtmlExportTest extends TestCase
$resp->assertDontSee('ExportWizardTheFifth');
}
public function test_page_export_only_includes_revision_count_if_user_has_revision_view_permissions()
{
$editor = $this->users->editor();
$page = $this->entities->page();
$resp = $this->actingAs($editor)->get($page->getUrl('/export/html'));
$resp->assertSee('Revision #');
$this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]);
$resp = $this->actingAs($editor)->get($page->getUrl('/export/html'));
$resp->assertDontSee('Revision #');
}
public function test_html_exports_contain_csp_meta_tag()
{
$entities = [

View File

@@ -79,6 +79,39 @@ class PdfExportTest extends TestCase
$this->assertStringContainsString('<details open="open"', $pdfHtml);
}
public function test_custom_fonts_loaded_for_dom_pdf_when_used()
{
// Set up custom font usage
$page = $this->entities->page()->forceFill([
'html' => '<p><strong>Bold</strong>text</p>',
]);
$page->save();
$this->setSettings([
'app-custom-head' => '<style>* { font-family: "meow words"}</style>'
]);
$normalFont = $this->files->testFilePath('fonts/Cardiff.ttf');
$normalFontTarget = storage_path('fonts/dompdf/MeowWords.ttf');
$boldFont = $this->files->testFilePath('fonts/Cardiff-Bold.ttf');
$boldFontTarget = storage_path('fonts/dompdf/MeowWords-Bold.ttf');
copy($normalFont, $normalFontTarget);
copy($boldFont, $boldFontTarget);
$resp = $this->asEditor()->get($page->getUrl('/export/pdf'));
$resp->assertStatus(200);
// Existance of UFM files indicates the metrics have been generated
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords.ufm'));
$this->assertFileExists(storage_path('fonts/dompdf/MeowWords-Bold.ufm'));
// Existence of cache json files indicates the fonts have been used
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords.ufm.json'));
$this->assertFileExists(storage_path('fonts/dompdf/cache/MeowWords-Bold.ufm.json'));
$filesToCleanUp = [...glob(storage_path('fonts/dompdf/Meow*')), ...glob(storage_path('fonts/dompdf/cache/Meow*'))];
foreach ($filesToCleanUp as $file) {
unlink($file);
}
}
public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true()
{
$page = $this->entities->page();

View File

@@ -52,7 +52,7 @@ class TextExportTest extends TestCase
$resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
$expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
$expected .= "My wonderful page!\nMy great page Full of great stuff";
$expected .= "My wonderful page!\nMy great page\nFull of great stuff";
$resp->assertSee($expected);
}
@@ -82,7 +82,7 @@ class TextExportTest extends TestCase
$resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
$expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
$expected .= "My wonderful page!\nMy great page Full of great stuff";
$expected .= "My wonderful page!\nMy great page\nFull of great stuff";
$resp->assertSee($expected);
}
}

View File

@@ -90,4 +90,29 @@ class ZipExportValidatorTest extends TestCase
$this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']);
}
public function test_page_link_attachments_cant_be_data_or_js()
{
$validateResultCountByLink = [
'data:text/html,<p>hi</p>' => 1,
'javascript:alert(\'hi\')' => 1,
'mailto:email@example.com' => 0,
];
foreach ($validateResultCountByLink as $link => $count) {
$validator = $this->getValidatorForData([
'page' => [
'id' => 4,
'name' => 'My page',
'markdown' => 'hello',
'attachments' => [
['id' => 4, 'name' => 'Attachment A', 'link' => $link],
],
]
]);
$results = $validator->validate();
$this->assertCount($count, $results);
}
}
}

View File

@@ -101,8 +101,9 @@ class PermissionsProvider
$this->addEntityPermissionEntries($entity, $permissions);
}
public function addEntityPermission(Entity $entity, array $actionList, Role $role)
public function setEntityPermissionsForRole(Entity $entity, array $actionList, Role $role)
{
$entity->permissions()->where('role_id', '=', $role->id)->delete();
$permissionData = $this->actionListToEntityPermissionData($actionList, $role->id);
$this->addEntityPermissionEntries($entity, [$permissionData]);
}

View File

@@ -29,8 +29,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$page = $this->entities->page();
$this->permissions->disableEntityInheritedPermissions($page);
$this->permissions->addEntityPermission($page, [], $roleA);
$this->permissions->addEntityPermission($page, ['view'], $roleB);
$this->permissions->setEntityPermissionsForRole($page, [], $roleA);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $roleB);
$this->assertVisibleToUser($page, $user);
}
@@ -42,7 +42,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->disableEntityInheritedPermissions($chapter);
$this->permissions->addEntityPermission($chapter, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -54,7 +54,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->disableEntityInheritedPermissions($chapter);
$this->permissions->addEntityPermission($chapter, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -67,8 +67,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->disableEntityInheritedPermissions($chapter);
$this->permissions->addEntityPermission($chapter, [], $roleA);
$this->permissions->addEntityPermission($chapter, ['view'], $roleB);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleB);
$this->assertVisibleToUser($page, $user);
}
@@ -80,8 +80,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->disableEntityInheritedPermissions($chapter);
$this->permissions->addEntityPermission($chapter, [], $roleA);
$this->permissions->addEntityPermission($page, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleA);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -93,8 +93,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->disableEntityInheritedPermissions($chapter);
$this->permissions->addEntityPermission($chapter, ['view'], $roleA);
$this->permissions->addEntityPermission($page, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($page, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -107,8 +107,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->disableEntityInheritedPermissions($chapter);
$this->permissions->addEntityPermission($page, [], $roleA);
$this->permissions->addEntityPermission($chapter, ['view'], $roleB);
$this->permissions->setEntityPermissionsForRole($page, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleB);
$this->assertVisibleToUser($page, $user);
}
@@ -121,8 +121,8 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->disableEntityInheritedPermissions($chapter);
$this->permissions->addEntityPermission($page, ['view'], $roleA);
$this->permissions->addEntityPermission($chapter, [], $roleB);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleB);
$this->assertVisibleToUser($page, $user);
}
@@ -131,7 +131,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
{
[$user, $roleA] = $this->users->newUserWithRole();
$page = $this->entities->page();
$this->permissions->addEntityPermission($page, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -140,7 +140,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
{
[$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']);
$page = $this->entities->page();
$this->permissions->addEntityPermission($page, [], $roleA);
$this->permissions->setEntityPermissionsForRole($page, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -150,7 +150,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
[$user, $roleA] = $this->users->newUserWithRole([], []);
$page = $this->entities->pageWithinChapter();
$chapter = $page->chapter;
$this->permissions->addEntityPermission($chapter, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -160,7 +160,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
[$user, $roleA] = $this->users->newUserWithRole([], ['page-view-all']);
$page = $this->entities->pageWithinChapter();
$chapter = $page->chapter;
$this->permissions->addEntityPermission($chapter, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -170,7 +170,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
[$user, $roleA] = $this->users->newUserWithRole([], ['page-view-own']);
$page = $this->entities->pageWithinChapter();
$chapter = $page->chapter;
$this->permissions->addEntityPermission($chapter, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleA);
$this->permissions->changeEntityOwner($page, $user);
$this->assertNotVisibleToUser($page, $user);
@@ -182,7 +182,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$roleB = $this->users->attachNewRole($user);
$page = $this->entities->page();
$this->permissions->addEntityPermission($page, [], $roleB);
$this->permissions->setEntityPermissionsForRole($page, [], $roleB);
$this->assertNotVisibleToUser($page, $user);
}
@@ -194,7 +194,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$page = $this->entities->page();
$this->permissions->changeEntityOwner($page, $user);
$this->permissions->addEntityPermission($page, [], $roleB);
$this->permissions->setEntityPermissionsForRole($page, [], $roleB);
$this->assertNotVisibleToUser($page, $user);
}
@@ -207,7 +207,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$page = $this->entities->pageWithinChapter();
$chapter = $page->chapter;
$this->permissions->addEntityPermission($chapter, [], $roleB);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleB);
$this->assertNotVisibleToUser($page, $user);
}
@@ -220,7 +220,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->changeEntityOwner($page, $user);
$this->permissions->addEntityPermission($chapter, [], $roleB);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleB);
$this->assertNotVisibleToUser($page, $user);
}
@@ -231,7 +231,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$page = $this->entities->page();
$this->permissions->setFallbackPermissions($page, []);
$this->permissions->addEntityPermission($page, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -241,7 +241,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$page = $this->entities->page();
$this->permissions->setFallbackPermissions($page, ['view']);
$this->permissions->addEntityPermission($page, [], $roleA);
$this->permissions->setEntityPermissionsForRole($page, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -253,7 +253,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$page = $this->entities->page();
$this->permissions->setFallbackPermissions($page, []);
$this->permissions->addEntityPermission($page, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -265,7 +265,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$page = $this->entities->page();
$this->permissions->setFallbackPermissions($page, ['view']);
$this->permissions->addEntityPermission($page, [], $roleA);
$this->permissions->setEntityPermissionsForRole($page, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -277,7 +277,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->setFallbackPermissions($chapter, []);
$this->permissions->addEntityPermission($chapter, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -289,7 +289,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->setFallbackPermissions($chapter, ['view']);
$this->permissions->addEntityPermission($chapter, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -302,7 +302,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->setFallbackPermissions($chapter, []);
$this->permissions->addEntityPermission($chapter, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA);
$this->assertVisibleToUser($page, $user);
}
@@ -315,7 +315,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$chapter = $page->chapter;
$this->permissions->setFallbackPermissions($chapter, ['view']);
$this->permissions->addEntityPermission($chapter, [], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, [], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -328,7 +328,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$this->permissions->setFallbackPermissions($chapter, []);
$this->permissions->setFallbackPermissions($page, []);
$this->permissions->addEntityPermission($chapter, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($chapter, ['view'], $roleA);
$this->assertNotVisibleToUser($page, $user);
}
@@ -342,7 +342,7 @@ class EntityRolePermissionsTest extends PermissionScenarioTestCase
$this->permissions->setFallbackPermissions($book, []);
$this->permissions->setFallbackPermissions($chapter, []);
$this->permissions->addEntityPermission($book, ['view'], $roleA);
$this->permissions->setEntityPermissionsForRole($book, ['view'], $roleA);
$this->assertNotVisibleToUser($page, $user);
}

View File

@@ -6,7 +6,6 @@ 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;
@@ -173,7 +172,7 @@ class PublicActionTest extends TestCase
$newRole = $this->users->attachNewRole($this->users->guest(), []);
$page = $this->entities->page();
$this->permissions->disableEntityInheritedPermissions($page);
$this->permissions->addEntityPermission($page, ['view', 'update'], $newRole);
$this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $newRole);
$resp = $this->get($page->getUrl());
$resp->assertOk();

View File

@@ -136,17 +136,21 @@ class EntitySearchTest extends TestCase
$page->tags()->saveMany([new Tag(['name' => 'DonkCount', 'value' => '500'])]);
$page->created_by = $this->users->admin()->id;
$page->save();
$otherPage = $this->entities->newPage(['name' => 'A different page in negation tests', 'html' => '<p>A different page in negation tests</p>']);
$editor = $this->users->editor();
$this->actingAs($editor);
$exactSearch = $this->get('/search?term=' . urlencode('negation -"tortoise"'));
$exactSearch->assertStatus(200)->assertDontSeeText($page->name);
$exactSearch->assertSeeText($otherPage->name);
$tagSearchA = $this->get('/search?term=' . urlencode('negation [DonkCount=500]'));
$tagSearchA->assertStatus(200)->assertSeeText($page->name);
$tagSearchA->assertDontSeeText($otherPage->name);
$tagSearchB = $this->get('/search?term=' . urlencode('negation -[DonkCount=500]'));
$tagSearchB->assertStatus(200)->assertDontSeeText($page->name);
$tagSearchB->assertSeeText($otherPage->name);
$filterSearchA = $this->get('/search?term=' . urlencode('negation -{created_by:me}'));
$filterSearchA->assertStatus(200)->assertSeeText($page->name);

View File

@@ -271,6 +271,21 @@ class BookSortTest extends TestCase
$this->withHtml($resp)->assertElementExists('select[name="auto-sort"] option[value="' . $sort->id . '"]');
}
public function test_auto_sort_rule_create_hint_shown_on_sort_page()
{
$book = $this->entities->book();
$hintText = 'Auto sort option rules can be created in the "Lists & Sorting" settings area by a user with the relevant permissions.';
// Admin users see link for creating new rule
$resp = $this->asAdmin()->get($book->getUrl('/sort'));
$this->withHtml($resp)->assertLinkExists(url('/settings/sorting/rules/new'), 'Create Sort Rule');
$resp->assertDontSee($hintText);
// Non-admin users see help text
$resp = $this->asEditor()->get($book->getUrl('/sort'));
$resp->assertSee($hintText);
}
public function test_auto_sort_option_submit_saves_to_book()
{
$sort = SortRule::factory()->create();

View File

@@ -1,62 +0,0 @@
<?php
namespace Tests\Unit;
use BookStack\Exceptions\HttpFetchException;
use BookStack\Util\SsrUrlValidator;
use Tests\TestCase;
class SsrUrlValidatorTest extends TestCase
{
public function test_allowed()
{
$testMap = [
// Single values
['config' => '', 'url' => '', 'result' => false],
['config' => '', 'url' => 'https://example.com', 'result' => false],
['config' => ' ', 'url' => 'https://example.com', 'result' => false],
['config' => '*', 'url' => '', 'result' => false],
['config' => '*', 'url' => 'https://example.com', 'result' => true],
['config' => 'https://*', 'url' => 'https://example.com', 'result' => true],
['config' => 'http://*', 'url' => 'https://example.com', 'result' => false],
['config' => 'https://*example.com', 'url' => 'https://example.com', 'result' => true],
['config' => 'https://*ample.com', 'url' => 'https://example.com', 'result' => true],
['config' => 'https://*.example.com', 'url' => 'https://example.com', 'result' => false],
['config' => 'https://*.example.com', 'url' => 'https://test.example.com', 'result' => true],
['config' => '*//example.com', 'url' => 'https://example.com', 'result' => true],
['config' => '*//example.com', 'url' => 'http://example.com', 'result' => true],
['config' => '*//example.co', 'url' => 'http://example.co.uk', 'result' => false],
['config' => '*//example.co/bookstack', 'url' => 'https://example.co/bookstack/a/path', 'result' => true],
['config' => '*//example.co*', 'url' => 'https://example.co.uk/bookstack/a/path', 'result' => true],
['config' => 'https://example.com', 'url' => 'https://example.com/a/b/c?test=cat', 'result' => true],
['config' => 'https://example.com', 'url' => 'https://example.co.uk', 'result' => false],
// Escapes
['config' => 'https://(.*?).com', 'url' => 'https://example.com', 'result' => false],
['config' => 'https://example.com', 'url' => 'https://example.co.uk#https://example.com', 'result' => false],
// Multi values
['config' => '*//example.org *//example.com', 'url' => 'https://example.com', 'result' => true],
['config' => '*//example.org *//example.com', 'url' => 'https://example.com/a/b/c?test=cat#hello', 'result' => true],
['config' => '*.example.org *.example.com', 'url' => 'https://example.co.uk', 'result' => false],
['config' => ' *.example.org *.example.com ', 'url' => 'https://example.co.uk', 'result' => false],
['config' => '* *.example.com', 'url' => 'https://example.co.uk', 'result' => true],
['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.co.uk', 'result' => true],
['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.net', 'result' => false],
];
foreach ($testMap as $test) {
$result = (new SsrUrlValidator($test['config']))->allowed($test['url']);
$this->assertEquals($test['result'], $result, "Failed asserting url '{$test['url']}' with config '{$test['config']}' results " . ($test['result'] ? 'true' : 'false'));
}
}
public function test_enssure_allowed()
{
$result = (new SsrUrlValidator('https://example.com'))->ensureAllowed('https://example.com');
$this->assertNull($result);
$this->expectException(HttpFetchException::class);
(new SsrUrlValidator('https://example.com'))->ensureAllowed('https://test.example.com');
}
}

View File

@@ -5,6 +5,7 @@ namespace Tests\Uploads;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Attachment;
use Tests\TestCase;
@@ -206,6 +207,21 @@ class AttachmentTest extends TestCase
$this->files->deleteAllAttachmentFiles();
}
public function test_attachment_deletion_requires_page_access()
{
$page = $this->entities->page();
$attachment = Attachment::factory()->create(['uploaded_to' => $page->id]);
$editor = $this->users->editor();
$this->permissions->disableEntityInheritedPermissions($page);
$this->permissions->grantUserRolePermissions($editor, [Permission::AttachmentDeleteAll]);
$resp = $this->actingAs($editor)->delete($attachment->getUrl());
$resp->assertNotFound();
$this->assertDatabaseHas('attachments', ['id' => $attachment->id]);
}
public function test_attachment_access_without_permission_shows_404()
{
$admin = $this->users->admin();

View File

@@ -72,6 +72,30 @@ class DrawioTest extends TestCase
$this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected');
}
public function test_base64_upload_requires_edit_permission_to_page()
{
$page = $this->entities->page();
$editor = $this->users->editor();
$this->actingAs($editor);
$this->permissions->disableEntityInheritedPermissions($page);
$upload = function () use ($page) {
return $this->postJson('images/drawio', [
'uploaded_to' => $page->id,
'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
]);
};
$upload()->assertStatus(404);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first());
$upload()->assertStatus(403);
$this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first());
$upload()->assertStatus(200);
}
public function test_drawio_url_can_be_configured()
{
config()->set('services.drawio', 'http://cats.com?dog=tree');

View File

@@ -36,6 +36,45 @@ class ImageTest extends TestCase
]);
}
public function test_image_upload_with_page_reference_requires_visibility_and_update_permissions_of_target_page()
{
$page = $this->entities->page();
$editor = $this->users->editor();
$this->permissions->disableEntityInheritedPermissions($page);
$this->actingAs($editor);
$resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id);
$resp->assertStatus(404);
$this->permissions->setEntityPermissionsForRole($page, ['view'], $editor->roles()->first());
$resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id);
$this->assertPermissionError($resp);
$this->permissions->setEntityPermissionsForRole($page, ['view', 'update'], $editor->roles()->first());
$resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id);
$resp->assertStatus(200);
$this->files->deleteAtRelativePath($resp->json('path'));
}
public function test_image_upload_with_page_reference_requires_page_to_exist()
{
$page = $this->entities->page();
$this->entities->destroy($page);
$editor = $this->users->editor();
$this->actingAs($editor);
$resp = $this->files->uploadGalleryImage($this, 'test-image.png', $page->id);
$resp->assertStatus(404);
$resp = $this->files->uploadGalleryImage($this, 'test-image.png', 0);
$resp->assertStatus(404);
}
public function test_image_display_thumbnail_generation_does_not_increase_image_size()
{
$page = $this->entities->page();
@@ -75,7 +114,7 @@ class ImageTest extends TestCase
public function test_image_display_thumbnail_generation_for_animated_avif_images_uses_original_file()
{
if (! function_exists('imageavif')) {
if ((gd_info()['AVIF Support'] ?? false) !== true) {
$this->markTestSkipped('imageavif() is not available');
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Tests\User;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Activity\ActivityType;
use BookStack\Permissions\Permission;
use Tests\TestCase;
class UserManagementMfaTest extends TestCase
{
public function test_configured_mfa_options_visible_on_user_edit()
{
$editor = $this->users->editor();
$resp = $this->asAdmin()->get($editor->getEditUrl());
$resp->assertSeeText('0 methods configured');
MfaValue::factory()->create(['user_id' => $editor->id, 'method' => MfaValue::METHOD_BACKUP_CODES]);
$resp = $this->get($editor->getEditUrl());
$resp->assertSeeText('1 method configured');
$resp->assertDontSeeText('0 methods configured');
}
public function test_reset_mfa_flow()
{
$editor = $this->users->editor();
MfaValue::factory()->create(['user_id' => $editor->id, 'method' => MfaValue::METHOD_BACKUP_CODES]);
MfaValue::factory()->create(['user_id' => $editor->id, 'method' => MfaValue::METHOD_TOTP]);
$this->assertEquals(2, $editor->mfaValues()->count());
$resp = $this->asAdmin()->get($editor->getEditUrl());
$this->withHtml($resp)->assertElementContains('form[action$="/mfa"] button[type="submit"]', 'Reset');
$resp = $this->delete($editor->getEditUrl('/mfa'));
$resp->assertRedirect($editor->getEditUrl());
$this->assertActivityExists(ActivityType::USER_MFA_RESET);
$resp = $this->followRedirects($resp);
$resp->assertSee('Multi-factor authentication methods reset');
$this->assertEquals(0, $editor->mfaValues()->count());
}
public function test_users_manage_permission_required_for_mfa_reset()
{
$editor = $this->users->editor();
$resp = $this->actingAs($editor)->delete($editor->getEditUrl('/mfa'));
$this->assertPermissionError($resp);
$this->permissions->grantUserRolePermissions($editor, [Permission::UsersManage]);
$resp = $this->delete($editor->getEditUrl('/mfa'));
$this->assertNotPermissionError($resp);
$resp->assertRedirect($editor->getEditUrl());
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Tests\Util;
use BookStack\Util\HtmlToPlainText;
use Tests\TestCase;
class HtmlToPlainTextTest extends TestCase
{
public function test_it_converts_html_to_plain_text()
{
$html = <<<HTML
<p>This is a test</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
<h2>A Header</h2>
<p>more &lt;&copy;&gt; text <strong>with bold</strong></p>
HTML;
$expected = <<<TEXT
This is a test
Item 1
Item 2
A Header
more <©> text with bold
TEXT;
$this->runTest($html, $expected);
}
public function test_adjacent_list_items_are_separated_by_newline()
{
$html = <<<HTML
<ul><li>Item A</li><li>Item B</li></ul>
HTML;
$expected = <<<TEXT
Item A
Item B
TEXT;
$this->runTest($html, $expected);
}
public function test_inline_formats_dont_cause_newlines()
{
$html = <<<HTML
<p><strong>H</strong><a>e</a><sup>l</sup><span>l</span><em>o</em></p>
HTML;
$expected = <<<TEXT
Hello
TEXT;
$this->runTest($html, $expected);
}
protected function runTest(string $html, string $expected): void
{
$converter = new HtmlToPlainText();
$result = $converter->convert(trim($html));
$this->assertEquals(trim($expected), $result);
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace Tests\Util;
use BookStack\Exceptions\HttpFetchException;
use BookStack\Util\SsrUrlValidator;
use Tests\TestCase;
class SsrUrlValidatorTest extends TestCase
{
public function test_is_uses_app_config_by_default()
{
config()->set([
'app.ssr_hosts' => 'https://donkey.example.com',
]);
$validator = new SsrUrlValidator();
$this->assertTrue($validator->allowed('https://donkey.example.com'));
$this->assertFalse($validator->allowed('https://monkey.example.com'));
}
public function test_config_string_can_be_passed_in_constructor()
{
config()->set([
'app.ssr_hosts' => 'https://donkey.example.com',
]);
$validator = new SsrUrlValidator('https://monkey.example.com');
$this->assertFalse($validator->allowed('https://donkey.example.com'));
$this->assertTrue($validator->allowed('https://monkey.example.com'));
}
public function test_config_string_can_include_multiple_space_seperated_values()
{
$validator = new SsrUrlValidator('https://monkey.example.com https://cat.example.com');
$this->assertFalse($validator->allowed('https://donkey.example.com'));
$this->assertTrue($validator->allowed('https://monkey.example.com'));
$this->assertTrue($validator->allowed('https://cat.example.com'));
}
public function test_ensure_allowed_throws_if_not_allowed()
{
$validator = new SsrUrlValidator('https://monkey.example.com');
$this->assertNull($validator->ensureAllowed('https://monkey.example.com'));
$this->assertThrows(function () use ($validator) {
$validator->ensureAllowed('https://donkey.example.com');
}, HttpFetchException::class, 'The URL does not match the configured allowed SSR hosts');
}
public function test_basic_url_matching()
{
$tests = [
// Single values
['config' => '', 'url' => '', 'result' => false],
['config' => '', 'url' => 'https://example.com', 'result' => false],
['config' => ' ', 'url' => 'https://example.com', 'result' => false],
['config' => '*', 'url' => '', 'result' => false],
['config' => '*', 'url' => 'https://example.com', 'result' => true],
['config' => 'https://*', 'url' => 'https://example.com', 'result' => true],
['config' => 'http://*', 'url' => 'https://example.com', 'result' => false],
['config' => 'https://*example.com', 'url' => 'https://example.com', 'result' => true],
['config' => 'https://*ample.com', 'url' => 'https://example.com', 'result' => true],
['config' => 'https://*.example.com', 'url' => 'https://example.com', 'result' => false],
['config' => 'https://*.example.com', 'url' => 'https://test.example.com', 'result' => true],
['config' => '*//example.com', 'url' => 'https://example.com', 'result' => true],
['config' => '*//example.com', 'url' => 'http://example.com', 'result' => true],
['config' => '*//example.co', 'url' => 'http://example.co.uk', 'result' => false],
['config' => '*//example.co/bookstack', 'url' => 'https://example.co/bookstack/a/path', 'result' => true],
['config' => '*//example.co*', 'url' => 'https://example.co.uk/bookstack/a/path', 'result' => true],
['config' => 'https://example.com', 'url' => 'https://example.com/a/b/c?test=cat', 'result' => true],
['config' => 'https://example.com', 'url' => 'https://example.co.uk', 'result' => false],
// Escapes
['config' => 'https://(.*?).com', 'url' => 'https://example.com', 'result' => false],
['config' => 'https://example.com', 'url' => 'https://example.co.uk#https://example.com', 'result' => false],
// Multi values
['config' => '*//example.org *//example.com', 'url' => 'https://example.com', 'result' => true],
['config' => '*//example.org *//example.com', 'url' => 'https://example.com/a/b/c?test=cat#hello', 'result' => true],
['config' => '*.example.org *.example.com', 'url' => 'https://example.co.uk', 'result' => false],
['config' => ' *.example.org *.example.com ', 'url' => 'https://example.co.uk', 'result' => false],
['config' => '* *.example.com', 'url' => 'https://example.co.uk', 'result' => true],
['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.co.uk', 'result' => true],
['config' => '*//example.org *//example.com *//example.co.uk', 'url' => 'https://example.net', 'result' => false],
// Further tests
['config' => 'https://monkey.example.com', 'url' => 'https://monkey.example.com/a/b', 'result' => true,],
['config' => 'https://monkey.example.com', 'url' => 'https://monkey.example.com/a/b?a=b#ab', 'result' => true,],
['config' => 'https://monkey.example.com', 'url' => 'https://monkey.example.com:8080/a', 'result' => false,],
['config' => '*', 'url' => 'https://a.example.com', 'result' => true,],
['config' => 'https://monkey.example.com', 'url' => 'http://monkey.example.com/a/b?a=b#ab', 'result' => false,],
['config' => 'https://monkey.example.com', 'url' => 'https://beans.monkey.example.com/a/b?a=b#ab', 'result' => false,],
['config' => 'https://*monkey.example.com', 'url' => 'https://amonkey.example.com/a/b?a=b#ab', 'result' => true,],
['config' => 'https://*monkey.example.com', 'url' => 'https://donkey.example.com/a/b/monkey.example.com/b?a=b#ab', 'result' => false,],
['config' => 'https://monkey.example.com', 'url' => 'https://example.com/monkey.example.com/b?a=monkey.example.com#monkey.example.com', 'result' => false,],
['config' => 'https://*.example.com', 'url' => 'https://a.b.example.com/a/b', 'result' => true,],
['config' => 'https://*.example.com', 'url' => 'https://a.b.example.a.com/a/b', 'result' => false,],
['config' => 'https://*.example.com', 'url' => 'https://a.com/a/b?val=a.example.com', 'result' => false,],
['config' => 'https://*.example.com', 'url' => 'https://a.com/a/b#example.com', 'result' => false,],
['config' => 'https://a.*.example.com', 'url' => 'https://a.b.c.example.com/c/d', 'result' => true,],
['config' => 'https://example.com/webhooks/', 'url' => 'https://example.com/webhooks/beans', 'result' => true,],
['config' => 'https://example.com/webhooks/', 'url' => 'https://example.com/a/webhooks/', 'result' => false,],
['config' => 'https://example.com:8080', 'url' => 'https://example.com/a/b', 'result' => false,],
['config' => 'https://example.com:8080', 'url' => 'https://example.com:8080/a/b', 'result' => true,],
['config' => 'https://example.com/*', 'url' => 'https://example.com:8080/a/b', 'result' => false,],
];
foreach ($tests as $testCase) {
$validator = new SsrUrlValidator($testCase['config']);
$result = $validator->allowed($testCase['url']);
$this->assertEquals($testCase['result'], $result, "Failed asserting expected result for config {$testCase['config']} and test value {$testCase['url']}");
}
}
public function test_wildcard_does_not_match_userinfo_data_but_still_allows_it()
{
$validator = new SsrUrlValidator('https://*monkey.example.com');
$this->assertFalse($validator->allowed('https://monkey.example.com@a.example.com'));
$validator = new SsrUrlValidator('https://monkey.example.com*');
$this->assertFalse($validator->allowed('https://monkey.example.com@a.example.com'));
$this->assertFalse($validator->allowed('https://monkey.example.com:monkey.example.com@a.example.com'));
$validator = new SsrUrlValidator('https://monkey.example.com');
$this->assertTrue($validator->allowed('https://a:b@monkey.example.com'));
}
public function test_percent_encoded_slashes_in_host_are_rejected()
{
$validator = new SsrUrlValidator('*');
$this->assertFalse($validator->allowed('https://cat.example.com%2Fa/b'));
$this->assertFalse($validator->allowed('https://cat.example.com%2fa/b'));
$this->assertFalse($validator->allowed('https://cat%2f.example.com/a/b'));
$this->assertFalse($validator->allowed('https://cat.exa%2Fmple.com'));
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,2 @@
Font files by Roger White, in public domain.
https://web.archive.org/web/20110609213636/http://www.rogersfonts.org.uk/