From 8f6f81948e81b4d63251bee57da57aa5809eaad2 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 3 Nov 2024 17:28:18 +0000
Subject: [PATCH] ZIP Imports: Fleshed out continue page, Added testing

---
 app/Exports/Controllers/ImportController.php  |  17 ++-
 app/Exports/Import.php                        |   9 ++
 lang/en/entities.php                          |   4 +
 resources/views/exports/import-show.blade.php |  41 ++++-
 routes/web.php                                |   1 +
 tests/Exports/ZipImportTest.php               | 140 ++++++++++++++++++
 6 files changed, 206 insertions(+), 6 deletions(-)

diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php
index 582fff975..787fd1b27 100644
--- a/app/Exports/Controllers/ImportController.php
+++ b/app/Exports/Controllers/ImportController.php
@@ -23,9 +23,8 @@ class ImportController extends Controller
      * Show the view to start a new import, and also list out the existing
      * in progress imports that are visible to the user.
      */
-    public function start(Request $request)
+    public function start()
     {
-        // TODO - Test visibility access for listed items
         $imports = $this->imports->getVisibleImports();
 
         $this->setPageTitle(trans('entities.import'));
@@ -64,7 +63,6 @@ class ImportController extends Controller
      */
     public function show(int $id)
     {
-        // TODO - Test visibility access
         $import = $this->imports->findVisible($id);
 
         $this->setPageTitle(trans('entities.import_continue'));
@@ -74,12 +72,23 @@ class ImportController extends Controller
         ]);
     }
 
+    public function run(int $id)
+    {
+        // TODO - Test access/visibility
+
+        $import = $this->imports->findVisible($id);
+
+        // TODO  - Run import
+           // Validate again before
+        // TODO - Redirect to result
+        // TOOD - Or redirect back with errors
+    }
+
     /**
      * Delete an active pending import from the filesystem and database.
      */
     public function delete(int $id)
     {
-        // TODO - Test visibility access
         $import = $this->imports->findVisible($id);
         $this->imports->deleteImport($import);
 
diff --git a/app/Exports/Import.php b/app/Exports/Import.php
index 520d8ea6c..8400382fd 100644
--- a/app/Exports/Import.php
+++ b/app/Exports/Import.php
@@ -3,11 +3,14 @@
 namespace BookStack\Exports;
 
 use BookStack\Activity\Models\Loggable;
+use BookStack\Users\Models\User;
 use Carbon\Carbon;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
+ * @property int $id
  * @property string $path
  * @property string $name
  * @property int $size - ZIP size in bytes
@@ -17,6 +20,7 @@ use Illuminate\Database\Eloquent\Model;
  * @property int $created_by
  * @property Carbon $created_at
  * @property Carbon $updated_at
+ * @property User $createdBy
  */
 class Import extends Model implements Loggable
 {
@@ -59,4 +63,9 @@ class Import extends Model implements Loggable
     {
         return "({$this->id}) {$this->name}";
     }
+
+    public function createdBy(): BelongsTo
+    {
+        return $this->belongsTo(User::class, 'created_by');
+    }
 }
diff --git a/lang/en/entities.php b/lang/en/entities.php
index e2d8e47c5..4f5a53004 100644
--- a/lang/en/entities.php
+++ b/lang/en/entities.php
@@ -51,7 +51,11 @@ return [
     'import_pending' => 'Pending Imports',
     'import_pending_none' => 'No imports have been started.',
     'import_continue' => 'Continue Import',
+    'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.',
     'import_run' => 'Run Import',
+    'import_size' => 'Import ZIP Size:',
+    'import_uploaded_at' => 'Uploaded:',
+    'import_uploaded_by' => 'Uploaded by:',
     'import_delete_confirm' => 'Are you sure you want to delete this import?',
     'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.',
 
diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php
index 843a05246..ac1b8a45d 100644
--- a/resources/views/exports/import-show.blade.php
+++ b/resources/views/exports/import-show.blade.php
@@ -6,7 +6,44 @@
 
         <main class="card content-wrap auto-height mt-xxl">
             <h1 class="list-heading">{{ trans('entities.import_continue') }}</h1>
-            <form action="{{ url('/import') }}" enctype="multipart/form-data" method="POST">
+            <p class="text-muted">{{ trans('entities.import_continue_desc') }}</p>
+
+            <div class="mb-m">
+                @php
+                    $type = $import->getType();
+                @endphp
+                <div class="flex-container-row items-center justify-space-between wrap">
+                    <div class="py-s">
+                        <p class="text-{{ $type }} mb-xs bold">@icon($type) {{ $import->name }}</p>
+                        @if($type === 'book')
+                            <p class="text-chapter mb-xs ml-l">@icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}</p>
+                        @endif
+                        @if($type === 'book' || $type === 'chapter')
+                            <p class="text-page mb-xs ml-l">@icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}</p>
+                        @endif
+                    </div>
+                    <div class="py-s">
+                        <div class="opacity-80">
+                            <strong>{{ trans('entities.import_size') }}</strong>
+                            <span>{{ $import->getSizeString() }}</span>
+                        </div>
+                        <div class="opacity-80">
+                            <strong>{{ trans('entities.import_uploaded_at') }}</strong>
+                            <span title="{{ $import->created_at->toISOString() }}">{{ $import->created_at->diffForHumans() }}</span>
+                        </div>
+                        @if($import->createdBy)
+                            <div class="opacity-80">
+                                <strong>{{ trans('entities.import_uploaded_by') }}</strong>
+                                <a href="{{ $import->createdBy->getProfileUrl() }}">{{ $import->createdBy->name }}</a>
+                            </div>
+                        @endif
+                    </div>
+                </div>
+            </div>
+
+            <form id="import-run-form"
+                  action="{{ $import->getUrl() }}"
+                  method="POST">
                 {{ csrf_field() }}
             </form>
 
@@ -23,7 +60,7 @@
                         <button type="submit" form="import-delete-form" class="text-link small text-item">{{ trans('common.confirm') }}</button>
                     </div>
                 </div>
-                <button type="submit" class="button">{{ trans('entities.import_run') }}</button>
+                <button type="submit" form="import-run-form" class="button">{{ trans('entities.import_run') }}</button>
             </div>
         </main>
     </div>
diff --git a/routes/web.php b/routes/web.php
index c490bb3b3..85f833528 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -210,6 +210,7 @@ Route::middleware('auth')->group(function () {
     Route::get('/import', [ExportControllers\ImportController::class, 'start']);
     Route::post('/import', [ExportControllers\ImportController::class, 'upload']);
     Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']);
+    Route::post('/import/{id}', [ExportControllers\ImportController::class, 'run']);
     Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']);
 
     // Other Pages
diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php
index c9d255b1e..b9a8598fa 100644
--- a/tests/Exports/ZipImportTest.php
+++ b/tests/Exports/ZipImportTest.php
@@ -2,6 +2,8 @@
 
 namespace Tests\Exports;
 
+use BookStack\Activity\ActivityType;
+use BookStack\Exports\Import;
 use Illuminate\Http\UploadedFile;
 use Illuminate\Testing\TestResponse;
 use Tests\TestCase;
@@ -35,6 +37,25 @@ class ZipImportTest extends TestCase
         $resp->assertSeeText('Select ZIP file to upload');
     }
 
+    public function test_import_page_pending_import_visibility_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $resp = $this->actingAs($user)->get('/import');
+        $resp->assertSeeText('MySuperUserImport');
+        $resp->assertDontSeeText('MySuperAdminImport');
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $resp = $this->actingAs($user)->get('/import');
+        $resp->assertSeeText('MySuperUserImport');
+        $resp->assertSeeText('MySuperAdminImport');
+    }
+
     public function test_zip_read_errors_are_shown_on_validation()
     {
         $invalidUpload = $this->files->uploadedImage('image.zip');
@@ -105,6 +126,125 @@ class ZipImportTest extends TestCase
         $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.');
     }
 
+    public function test_import_upload_success()
+    {
+        $admin = $this->users->admin();
+        $this->actingAs($admin);
+        $resp = $this->runImportFromFile($this->zipUploadFromData([
+            'book' => [
+                'name' => 'My great book name',
+                'chapters' => [
+                    [
+                        'name' => 'my chapter',
+                        'pages' => [
+                            [
+                                'name' => 'my chapter page',
+                            ]
+                        ]
+                    ]
+                ],
+                'pages' => [
+                    [
+                        'name' => 'My page',
+                    ]
+                ],
+            ],
+        ]));
+
+        $this->assertDatabaseHas('imports', [
+            'name' => 'My great book name',
+            'book_count' => 1,
+            'chapter_count' => 1,
+            'page_count' => 2,
+            'created_by' => $admin->id,
+        ]);
+
+        /** @var Import $import */
+        $import = Import::query()->latest()->first();
+        $resp->assertRedirect("/import/{$import->id}");
+        $this->assertFileExists(storage_path($import->path));
+        $this->assertActivityExists(ActivityType::IMPORT_CREATE);
+    }
+
+    public function test_import_show_page()
+    {
+        $import = Import::factory()->create(['name' => 'MySuperAdminImport']);
+
+        $resp = $this->asAdmin()->get("/import/{$import->id}");
+        $resp->assertOk();
+        $resp->assertSee('MySuperAdminImport');
+    }
+
+    public function test_import_show_page_access_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->get("/import/{$userImport->id}")->assertRedirect('/');
+        $this->get("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->get("/import/{$userImport->id}")->assertOk();
+        $this->get("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->get("/import/{$userImport->id}")->assertOk();
+        $this->get("/import/{$adminImport->id}")->assertOk();
+    }
+
+    public function test_import_delete()
+    {
+        $this->asAdmin();
+        $this->runImportFromFile($this->zipUploadFromData([
+            'book' => [
+                'name' => 'My great book name'
+            ],
+        ]));
+
+        /** @var Import $import */
+        $import = Import::query()->latest()->first();
+        $this->assertDatabaseHas('imports', [
+            'id' => $import->id,
+            'name' => 'My great book name'
+        ]);
+        $this->assertFileExists(storage_path($import->path));
+
+        $resp = $this->delete("/import/{$import->id}");
+
+        $resp->assertRedirect('/import');
+        $this->assertActivityExists(ActivityType::IMPORT_DELETE);
+        $this->assertDatabaseMissing('imports', [
+            'id' => $import->id,
+        ]);
+        $this->assertFileDoesNotExist(storage_path($import->path));
+    }
+
+    public function test_import_delete_access_limited()
+    {
+        $user = $this->users->viewer();
+        $admin = $this->users->admin();
+        $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]);
+        $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]);
+        $this->actingAs($user);
+
+        $this->delete("/import/{$userImport->id}")->assertRedirect('/');
+        $this->delete("/import/{$adminImport->id}")->assertRedirect('/');
+
+        $this->permissions->grantUserRolePermissions($user, ['content-import']);
+
+        $this->delete("/import/{$userImport->id}")->assertRedirect('/import');
+        $this->delete("/import/{$adminImport->id}")->assertStatus(404);
+
+        $this->permissions->grantUserRolePermissions($user, ['settings-manage']);
+
+        $this->delete("/import/{$adminImport->id}")->assertRedirect('/import');
+    }
+
     protected function runImportFromFile(UploadedFile $file): TestResponse
     {
         return $this->call('POST', '/import', [], [], ['file' => $file]);