From: Dan Brown Date: Fri, 18 Jul 2025 13:05:32 +0000 (+0100) Subject: ZIP Imports: Added API test cases X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/73025719a4bc06457de1d753aa6e6fefd0ba8777 ZIP Imports: Added API test cases --- diff --git a/app/Exports/Controllers/ImportApiController.php b/app/Exports/Controllers/ImportApiController.php index 13bc9d83e..0749ff933 100644 --- a/app/Exports/Controllers/ImportApiController.php +++ b/app/Exports/Controllers/ImportApiController.php @@ -26,9 +26,11 @@ class ImportApiController extends ApiController */ public function list(): JsonResponse { - $imports = $this->imports->getVisibleImports()->all(); + $query = $this->imports->queryVisible(); - return response()->json($imports); + return $this->apiListingResponse($query, [ + 'id', 'name', 'size', 'type', 'created_by', 'created_at', 'updated_at' + ]); } /** @@ -44,7 +46,7 @@ class ImportApiController extends ApiController try { $import = $this->imports->storeFromUpload($file); } catch (ZipValidationException $exception) { - $message = "ZIP upload failed with the following validation errors: \n" . implode("\n", $exception->errors); + $message = "ZIP upload failed with the following validation errors: \n" . $this->formatErrors($exception->errors); return $this->jsonError($message, 422); } @@ -53,11 +55,15 @@ class ImportApiController extends ApiController /** * Read details of a pending ZIP import. + * The "details" property contains high-level metadata regarding the ZIP import content, + * and the structure of this will change depending on import "type". */ public function read(int $id): JsonResponse { $import = $this->imports->findVisible($id); + $import->setAttribute('details', $import->decodeMetadata()); + return response()->json($import); } @@ -82,7 +88,7 @@ class ImportApiController extends ApiController try { $entity = $this->imports->runImport($import, $parent); } catch (ZipImportException $exception) { - $message = "ZIP import failed with the following errors: \n" . implode("\n", $exception->errors); + $message = "ZIP import failed with the following errors: \n" . $this->formatErrors($exception->errors); return $this->jsonError($message); } @@ -112,4 +118,17 @@ class ImportApiController extends ApiController ], ]; } + + protected function formatErrors(array $errors): string + { + $parts = []; + foreach ($errors as $key => $error) { + if (is_string($key)) { + $parts[] = "[{$key}] {$error}"; + } else { + $parts[] = $error; + } + } + return implode("\n", $parts); + } } diff --git a/app/Exports/Import.php b/app/Exports/Import.php index 9c1771c46..ca4f52981 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -28,6 +28,8 @@ class Import extends Model implements Loggable { use HasFactory; + protected $hidden = ['metadata']; + public function getSizeString(): string { $mb = round($this->size / 1000000, 2); diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index f72386c47..e030a88d2 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -17,6 +17,7 @@ use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Facades\Activity; use BookStack\Uploads\FileStorage; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -34,6 +35,11 @@ class ImportRepo * @return Collection */ public function getVisibleImports(): Collection + { + return $this->queryVisible()->get(); + } + + public function queryVisible(): Builder { $query = Import::query(); @@ -41,7 +47,7 @@ class ImportRepo $query->where('created_by', user()->id); } - return $query->get(); + return $query; } public function findVisible(int $id): Import diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index c0dbe2fca..1a92afa33 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -8,7 +8,7 @@ use Illuminate\Http\JsonResponse; abstract class ApiController extends Controller { - protected $rules = []; + protected array $rules = []; /** * Provide a paginated listing JSON response in a standard format diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php index 5d0b4f892..cdb019dd3 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -24,6 +24,7 @@ class ImportFactory extends Factory 'path' => 'uploads/files/imports/' . Str::random(10) . '.zip', 'name' => $this->faker->words(3, true), 'type' => 'book', + 'size' => rand(1, 1001), 'metadata' => '{"name": "My book"}', 'created_at' => User::factory(), ]; diff --git a/routes/api.php b/routes/api.php index efb7b258c..98af4bb26 100644 --- a/routes/api.php +++ b/routes/api.php @@ -88,11 +88,11 @@ Route::get('roles/{id}', [RoleApiController::class, 'read']); Route::put('roles/{id}', [RoleApiController::class, 'update']); Route::delete('roles/{id}', [RoleApiController::class, 'delete']); -Route::get('import', [ExportControllers\ImportApiController::class, 'list']); -Route::post('import', [ExportControllers\ImportApiController::class, 'upload']); -Route::get('import/{id}', [ExportControllers\ImportApiController::class, 'read']); -Route::post('import/{id}', [ExportControllers\ImportApiController::class, 'run']); -Route::delete('import/{id}', [ExportControllers\ImportApiController::class, 'delete']); +Route::get('imports', [ExportControllers\ImportApiController::class, 'list']); +Route::post('imports', [ExportControllers\ImportApiController::class, 'upload']); +Route::get('imports/{id}', [ExportControllers\ImportApiController::class, 'read']); +Route::post('imports/{id}', [ExportControllers\ImportApiController::class, 'run']); +Route::delete('imports/{id}', [ExportControllers\ImportApiController::class, 'delete']); Route::get('recycle-bin', [EntityControllers\RecycleBinApiController::class, 'list']); Route::put('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiController::class, 'restore']); diff --git a/tests/Api/ImportsApiTest.php b/tests/Api/ImportsApiTest.php new file mode 100644 index 000000000..523034324 --- /dev/null +++ b/tests/Api/ImportsApiTest.php @@ -0,0 +1,175 @@ +entities->book(); + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'name' => 'My API import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $importId = $resp->json('id'); + $import = Import::query()->findOrFail($importId); + $this->assertEquals('page', $import->type); + + $resp = $this->post($this->baseEndpoint . "/{$import->id}", [ + 'parent_type' => 'book', + 'parent_id' => $book->id, + ]); + $resp->assertJson([ + 'name' => 'My API import page', + 'book_id' => $book->id, + ]); + + $page = Page::query()->where('name', '=', 'My API import page')->first(); + $this->assertEquals('My api tag', $page->tags()->first()->name); + } + + public function test_upload_validation_error(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'page' => [ + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(422); + $message = $resp->json('message'); + + $this->assertStringContainsString('ZIP upload failed with the following validation errors:', $message); + $this->assertStringContainsString('[page.name] The name field is required.', $message); + } + + public function test_list(): void + { + $imports = Import::factory()->count(10)->create(); + + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint); + $resp->assertJsonCount(10, 'data'); + $resp->assertJsonPath('total', 10); + + $firstImport = $imports->first(); + $resp = $this->actingAsApiAdmin()->get($this->baseEndpoint . '?filter[id]=' . $firstImport->id); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.id', $firstImport->id); + $resp->assertJsonPath('data.0.name', $firstImport->name); + $resp->assertJsonPath('data.0.size', $firstImport->size); + $resp->assertJsonPath('data.0.type', $firstImport->type); + } + + public function test_list_visibility_limited(): void + { + $user = $this->users->editor(); + $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->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(1, 'data'); + $resp->assertJsonPath('data.0.name', 'MySuperUserImport'); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $resp = $this->actingAsForApi($user)->get($this->baseEndpoint); + $resp->assertJsonCount(2, 'data'); + $resp->assertJsonPath('data.1.name', 'MySuperAdminImport'); + } + + public function test_read(): void + { + $zip = ZipTestHelper::zipUploadFromData([ + 'book' => [ + 'name' => 'My API import book', + 'pages' => [ + [ + 'name' => 'My import page', + 'tags' => [ + [ + 'name' => 'My api tag', + 'value' => 'api test value' + ] + ] + ] + ], + ], + ]); + + $resp = $this->actingAsApiAdmin()->call('POST', $this->baseEndpoint, [], [], ['file' => $zip]); + $resp->assertStatus(200); + + $resp = $this->get($this->baseEndpoint . "/{$resp->json('id')}"); + $resp->assertStatus(200); + + $resp->assertJsonPath('details.name', 'My API import book'); + $resp->assertJsonPath('details.pages.0.name', 'My import page'); + $resp->assertJsonPath('details.pages.0.tags.0.name', 'My api tag'); + $resp->assertJsonMissingPath('metadata'); + } + + public function test_delete(): void + { + $import = Import::factory()->create(); + + $resp = $this->actingAsApiAdmin()->delete($this->baseEndpoint . "/{$import->id}"); + $resp->assertStatus(204); + } + + public function test_content_import_permissions_needed(): void + { + $user = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($user, ['access-api']); + $this->actingAsForApi($user); + $requests = [ + ['GET', $this->baseEndpoint], + ['POST', $this->baseEndpoint], + ['GET', $this->baseEndpoint . "/1"], + ['POST', $this->baseEndpoint . "/1"], + ['DELETE', $this->baseEndpoint . "/1"], + ]; + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->json($method, $endpoint); + $resp->assertStatus(403); + } + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + foreach ($requests as $request) { + [$method, $endpoint] = $request; + $resp = $this->call($method, $endpoint); + $this->assertNotEquals(403, $resp->status(), "A {$method} request to {$endpoint} returned 403"); + } + } +}