1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2026-10-24 18:59:20 +03:00
Files
bookstack/tests/Settings/RecycleBinTest.php
Dan Brown 82a8cc35cf Recycle Bin: Ensure permissions are updated for deleted items
Fixed lack of permission update scope in permission builder where
trashed pages on chapter permission updates would not get permission
updates.

Also added permission rebuild on restore as an extra assurance.
Added tests to cover.

Thanks to SVO — svo.com.br for reporting.
2026-08-23 14:24:34 +01:00

320 lines
14 KiB
PHP

<?php
namespace Tests\Settings;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class RecycleBinTest extends TestCase
{
public function test_recycle_bin_routes_permissions()
{
$page = $this->entities->page();
$editor = $this->users->editor();
$this->actingAs($editor)->delete($page->getUrl());
$deletion = Deletion::query()->firstOrFail();
$routes = [
'GET:/settings/recycle-bin',
'POST:/settings/recycle-bin/empty',
"GET:/settings/recycle-bin/{$deletion->id}/destroy",
"GET:/settings/recycle-bin/{$deletion->id}/restore",
"POST:/settings/recycle-bin/{$deletion->id}/restore",
"DELETE:/settings/recycle-bin/{$deletion->id}",
];
foreach ($routes as $route) {
[$method, $url] = explode(':', $route);
$resp = $this->call($method, $url);
$this->assertPermissionError($resp);
}
$this->permissions->grantUserRolePermissions($editor, ['restrictions-manage-all']);
foreach ($routes as $route) {
[$method, $url] = explode(':', $route);
$resp = $this->call($method, $url);
$this->assertPermissionError($resp);
}
$this->permissions->grantUserRolePermissions($editor, ['settings-manage']);
foreach ($routes as $route) {
DB::beginTransaction();
[$method, $url] = explode(':', $route);
$resp = $this->call($method, $url);
$this->assertNotPermissionError($resp);
DB::rollBack();
}
}
public function test_recycle_bin_view()
{
$page = $this->entities->page();
$book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first();
$editor = $this->users->editor();
$this->actingAs($editor)->delete($page->getUrl());
$this->actingAs($editor)->delete($book->getUrl());
$viewReq = $this->asAdmin()->get('/settings/recycle-bin');
$html = $this->withHtml($viewReq);
$html->assertElementContains('.item-list-row', $page->name);
$html->assertElementContains('.item-list-row', $editor->name);
$html->assertElementContains('.item-list-row', $book->name);
$html->assertElementContains('.item-list-row', $book->pages_count . ' Pages');
$html->assertElementContains('.item-list-row', $book->chapters_count . ' Chapters');
}
public function test_recycle_bin_empty()
{
$page = $this->entities->page();
$book = Book::query()->where('id', '!=', $page->book_id)->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
$editor = $this->users->editor();
$this->actingAs($editor)->delete($page->getUrl());
$this->actingAs($editor)->delete($book->getUrl());
$this->assertTrue(Deletion::query()->count() === 2);
$emptyReq = $this->asAdmin()->post('/settings/recycle-bin/empty');
$emptyReq->assertRedirect('/settings/recycle-bin');
$this->assertTrue(Deletion::query()->count() === 0);
$this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
$this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
$this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
$this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
$this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
$this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
$itemCount = 2 + $book->pages->count() + $book->chapters->count();
$redirectReq = $this->get('/settings/recycle-bin');
$this->assertNotificationContains($redirectReq, 'Deleted ' . $itemCount . ' total items from the recycle bin');
}
public function test_entity_restore()
{
$book = $this->entities->bookHasChaptersAndPages();
$this->asEditor()->delete($book->getUrl())->assertRedirect();
$deletion = Deletion::query()->firstOrFail();
$this->assertEquals($book->pages->count(), Page::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
$this->assertEquals($book->chapters->count(), Chapter::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
$restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
$restoreReq->assertRedirect('/settings/recycle-bin');
$this->assertTrue(Deletion::query()->count() === 0);
$this->assertEquals($book->pages->count(), Page::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
$this->assertEquals($book->chapters->count(), Chapter::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
$itemCount = 1 + $book->pages->count() + $book->chapters->count();
$redirectReq = $this->get('/settings/recycle-bin');
$this->assertNotificationContains($redirectReq, 'Restored ' . $itemCount . ' total items from the recycle bin');
}
public function test_permanent_delete()
{
$book = $this->entities->bookHasChaptersAndPages();
$this->asEditor()->delete($book->getUrl());
$deletion = Deletion::query()->firstOrFail();
$deleteReq = $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
$deleteReq->assertRedirect('/settings/recycle-bin');
$this->assertTrue(Deletion::query()->count() === 0);
$this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']);
$this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']);
$this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']);
$this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]);
$this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']);
$this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']);
$itemCount = 1 + $book->pages->count() + $book->chapters->count();
$redirectReq = $this->get('/settings/recycle-bin');
$this->assertNotificationContains($redirectReq, 'Deleted ' . $itemCount . ' total items from the recycle bin');
}
public function test_permanent_delete_for_each_type()
{
foreach ($this->entities->all() as $type => $entity) {
$this->asEditor()->delete($entity->getUrl());
$deletion = Deletion::query()->orderBy('id', 'desc')->firstOrFail();
$deleteReq = $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
$deleteReq->assertRedirect('/settings/recycle-bin');
$this->assertDatabaseMissing('deletions', ['id' => $deletion->id]);
$this->assertDatabaseMissing($entity->getTable(), ['id' => $entity->id]);
}
}
public function test_permanent_entity_delete_updates_existing_activity_with_entity_name()
{
$page = $this->entities->page();
$this->asEditor()->delete($page->getUrl());
$deletion = $page->deletions()->firstOrFail();
$this->assertDatabaseHas('activities', [
'type' => 'page_delete',
'loggable_id' => $page->id,
'loggable_type' => $page->getMorphClass(),
]);
$this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
$this->assertDatabaseMissing('activities', [
'type' => 'page_delete',
'loggable_id' => $page->id,
'loggable_type' => $page->getMorphClass(),
]);
$this->assertDatabaseHas('activities', [
'type' => 'page_delete',
'loggable_id' => null,
'loggable_type' => null,
'detail' => $page->name,
]);
}
public function test_permanent_book_delete_removes_shelf_relation_data()
{
$book = $this->entities->book();
$shelf = $this->entities->shelf();
$shelf->books()->attach($book);
$this->assertDatabaseHas('bookshelves_books', ['book_id' => $book->id]);
$this->asEditor()->delete($book->getUrl());
$deletion = $book->deletions()->firstOrFail();
$this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
$this->assertDatabaseMissing('bookshelves_books', ['book_id' => $book->id]);
}
public function test_permanent_shelf_delete_removes_book_relation_data()
{
$book = $this->entities->book();
$shelf = $this->entities->shelf();
$shelf->books()->attach($book);
$this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id]);
$this->asEditor()->delete($shelf->getUrl());
$deletion = $shelf->deletions()->firstOrFail();
$this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect();
$this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
}
public function test_auto_clear_functionality_works()
{
config()->set('app.recycle_bin_lifetime', 5);
$page = $this->entities->page();
$otherPage = $this->entities->page();
$this->asEditor()->delete($page->getUrl());
$this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
$this->assertEquals(1, Deletion::query()->count());
Carbon::setTestNow(Carbon::now()->addDays(6));
$this->asEditor()->delete($otherPage->getUrl());
$this->assertEquals(1, Deletion::query()->count());
$this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
}
public function test_auto_clear_functionality_with_negative_time_keeps_forever()
{
config()->set('app.recycle_bin_lifetime', -1);
$page = $this->entities->page();
$otherPage = $this->entities->page();
$this->asEditor()->delete($page->getUrl());
$this->assertEquals(1, Deletion::query()->count());
Carbon::setTestNow(Carbon::now()->addDays(6000));
$this->asEditor()->delete($otherPage->getUrl());
$this->assertEquals(2, Deletion::query()->count());
$this->assertDatabaseHasEntityData('page', ['id' => $page->id]);
}
public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
{
config()->set('app.recycle_bin_lifetime', 0);
$page = $this->entities->page();
$this->asEditor()->delete($page->getUrl());
$this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']);
$this->assertEquals(0, Deletion::query()->count());
}
public function test_restore_flow_when_restoring_nested_delete_first()
{
$book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
$chapter = $book->chapters->first();
$this->asEditor()->delete($chapter->getUrl());
$this->asEditor()->delete($book->getUrl());
$bookDeletion = $book->deletions()->first();
$chapterDeletion = $chapter->deletions()->first();
$chapterRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$chapterDeletion->id}/restore");
$chapterRestoreView->assertStatus(200);
$chapterRestoreView->assertSeeText($chapter->name);
$chapterRestore = $this->post("/settings/recycle-bin/{$chapterDeletion->id}/restore");
$chapterRestore->assertRedirect('/settings/recycle-bin');
$this->assertDatabaseMissing('deletions', ['id' => $chapterDeletion->id]);
$chapter->refresh();
$this->assertNotNull($chapter->deleted_at);
$bookRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$bookDeletion->id}/restore");
$bookRestoreView->assertStatus(200);
$bookRestoreView->assertSeeText($chapter->name);
$this->post("/settings/recycle-bin/{$bookDeletion->id}/restore");
$chapter->refresh();
$this->assertNull($chapter->deleted_at);
}
public function test_restore_page_shows_link_to_parent_restore_if_parent_also_deleted()
{
$book = $this->entities->bookHasChaptersAndPages();
$chapter = $book->chapters->first();
/** @var Page $page */
$page = $chapter->pages->first();
$this->asEditor()->delete($page->getUrl());
$this->asEditor()->delete($book->getUrl());
$bookDeletion = $book->deletions()->first();
$pageDeletion = $page->deletions()->first();
$pageRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$pageDeletion->id}/restore");
$pageRestoreView->assertSee('The parent of this item has also been deleted.');
$this->withHtml($pageRestoreView)->assertElementContains('a[href$="/settings/recycle-bin/' . $bookDeletion->id . '/restore"]', 'Restore Parent');
}
public function test_restore_action_rebuilds_permissions_for_entity()
{
$book = $this->entities->bookHasChaptersAndPages();
/** @var Page $page */
$page = $book->pages()->first();
$this->asEditor()->delete($book->getUrl());
$bookDeletion = $book->deletions()->first();
DB::table('joint_permissions')->where('entity_id', '=', $book->id)
->where('entity_type', '=', 'book')->delete();
DB::table('joint_permissions')->where('entity_id', '=', $page->id)
->where('entity_type', '=', 'page')->delete();
$this->asAdmin()->post("/settings/recycle-bin/{$bookDeletion->id}/restore");
$this->assertDatabaseHas('joint_permissions', ['entity_id' => $book->id, 'entity_type' => 'book']);
$this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id, 'entity_type' => 'page']);
}
}