From 3a808fd76859a90cda0d6a4085bed053d7cabde1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Mar 2023 19:29:08 +0000 Subject: [PATCH] Added phpunit tests to cover image API endpoints --- .../Api/ImageGalleryApiController.php | 13 +- app/Uploads/Image.php | 2 +- tests/Api/ImageGalleryApiTest.php | 347 ++++++++++++++++++ tests/Api/TestsApi.php | 21 +- 4 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 tests/Api/ImageGalleryApiTest.php diff --git a/app/Http/Controllers/Api/ImageGalleryApiController.php b/app/Http/Controllers/Api/ImageGalleryApiController.php index 85c0c3cef..a9fb3b103 100644 --- a/app/Http/Controllers/Api/ImageGalleryApiController.php +++ b/app/Http/Controllers/Api/ImageGalleryApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Http\Controllers\Api; +use BookStack\Entities\Models\Page; use BookStack\Uploads\Image; use BookStack\Uploads\ImageRepo; use Illuminate\Http\Request; @@ -22,7 +23,7 @@ class ImageGalleryApiController extends ApiController return [ 'create' => [ 'type' => ['required', 'string', 'in:gallery,drawio'], - 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'uploaded_to' => ['required', 'integer'], 'image' => ['required', 'file', ...$this->getImageValidationRules()], 'name' => ['string', 'max:180'], ], @@ -52,10 +53,17 @@ class ImageGalleryApiController extends ApiController */ public function create(Request $request) { + $this->checkPermission('image-create-all'); $data = $this->validate($request, $this->rules()['create']); + Page::visible()->findOrFail($data['uploaded_to']); $image = $this->imageRepo->saveNew($data['image'], $data['type'], $data['uploaded_to']); + if (isset($data['name'])) { + $image->refresh(); + $image->update(['name' => $data['name']]); + } + return response()->json($this->formatForSingleResponse($image)); } @@ -64,8 +72,7 @@ class ImageGalleryApiController extends ApiController */ public function read(string $id) { - $image = $this->imageRepo->getById($id); - $this->checkOwnablePermission('page-view', $image->getPage()); + $image = Image::query()->scopes(['visible'])->findOrFail($id); return response()->json($this->formatForSingleResponse($image)); } diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index 038e7c199..0ab0b612a 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -49,7 +49,7 @@ class Image extends Model * * @throws \Exception */ - public function getThumb(int $width, int $height, bool $keepRatio = false): string + public function getThumb(?int $width, ?int $height, bool $keepRatio = false): string { return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio); } diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php new file mode 100644 index 000000000..17c90518c --- /dev/null +++ b/tests/Api/ImageGalleryApiTest.php @@ -0,0 +1,347 @@ +actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $image->id, + 'name' => $image->name, + 'url' => $image->url, + 'path' => $image->path, + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'created_by' => $this->users->admin()->id, + 'updated_by' => $this->users->admin()->id, + ], + ]]); + + $resp->assertJson(['total' => Image::query()->count()]); + } + + public function test_index_endpoint_doesnt_show_images_for_those_uploaded_to_non_visible_pages() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $image->id); + $resp->assertJsonCount(1, 'data'); + $resp->assertJson(['total' => 1]); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $image->id); + $resp->assertJsonCount(0, 'data'); + $resp->assertJson(['total' => 0]); + } + + public function test_index_endpoint_doesnt_show_other_image_types() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $typesByCountExpectation = [ + 'cover_book' => 0, + 'drawio' => 1, + 'gallery' => 1, + 'user' => 0, + 'system' => 0, + ]; + + foreach ($typesByCountExpectation as $type => $count) { + $image->type = $type; + $image->save(); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $image->id); + $resp->assertJsonCount($count, 'data'); + $resp->assertJson(['total' => $count]); + } + } + + public function test_create_endpoint() + { + $this->actingAsApiAdmin(); + + $imagePage = $this->entities->page(); + $resp = $this->call('POST', $this->baseEndpoint, [ + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'name' => 'My awesome image!', + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + + $resp->assertStatus(200); + + $image = Image::query()->where('uploaded_to', '=', $imagePage->id)->first(); + $expectedUser = [ + 'id' => $this->users->admin()->id, + 'name' => $this->users->admin()->name, + 'slug' => $this->users->admin()->slug, + ]; + $resp->assertJson([ + 'id' => $image->id, + 'name' => 'My awesome image!', + 'url' => $image->url, + 'path' => $image->path, + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'created_by' => $expectedUser, + 'updated_by' => $expectedUser, + ]); + } + + public function test_create_endpoint_requires_image_create_permissions() + { + $user = $this->users->editor(); + $this->actingAsForApi($user); + $this->permissions->removeUserRolePermissions($user, ['image-create-all']); + + $makeRequest = function () { + return $this->call('POST', $this->baseEndpoint, []); + }; + + $resp = $makeRequest(); + $resp->assertStatus(403); + + $this->permissions->grantUserRolePermissions($user, ['image-create-all']); + + $resp = $makeRequest(); + $resp->assertStatus(422); + } + + public function test_create_fails_if_uploaded_to_not_visible_or_not_exists() + { + $this->actingAsApiEditor(); + + $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); + $resp = $makeRequest($page->id); + $resp->assertStatus(404); + + $resp = $makeRequest(Page::query()->max('id') + 55); + $resp->assertStatus(404); + } + + public function test_create_has_restricted_types() + { + $this->actingAsApiEditor(); + + $typesByStatusExpectation = [ + 'cover_book' => 422, + 'drawio' => 200, + 'gallery' => 200, + 'user' => 422, + 'system' => 422, + ]; + + $makeRequest = function (string $type) { + return $this->call('POST', $this->baseEndpoint, [ + 'type' => $type, + 'uploaded_to' => $this->entities->page()->id, + 'name' => 'My awesome image!', + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + }; + + foreach ($typesByStatusExpectation as $type => $status) { + $resp = $makeRequest($type); + $resp->assertStatus($status); + } + } + + public function test_create_will_use_file_name_if_no_name_provided_in_request() + { + $this->actingAsApiEditor(); + + $imagePage = $this->entities->page(); + $resp = $this->call('POST', $this->baseEndpoint, [ + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + $resp->assertStatus(200); + + $this->assertDatabaseHas('images', [ + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'name' => 'my-cool-image.png', + ]); + } + + public function test_read_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->getJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(200); + + $expectedUser = [ + 'id' => $this->users->admin()->id, + 'name' => $this->users->admin()->name, + 'slug' => $this->users->admin()->slug, + ]; + + $displayUrl = $image->getThumb(1680, null, true); + $resp->assertJson([ + 'id' => $image->id, + 'name' => $image->name, + 'url' => $image->url, + 'path' => $image->path, + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'created_by' => $expectedUser, + 'updated_by' => $expectedUser, + 'content' => [ + 'html' => "url}\" target=\"_blank\">\"{$image-name}\">", + 'markdown' => "![{$image->name}]({$displayUrl})", + ], + ]); + $this->assertStringStartsWith('http://', $resp->json('thumbs.gallery')); + $this->assertStringStartsWith('http://', $resp->json('thumbs.display')); + } + + public function test_read_endpoint_provides_different_content_for_drawings() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $image->type = 'drawio'; + $image->save(); + + $resp = $this->getJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(200); + + $drawing = "
id}\">url}\">
"; + $resp->assertJson([ + 'id' => $image->id, + 'content' => [ + 'html' => $drawing, + 'markdown' => $drawing, + ], + ]); + } + + public function test_read_endpoint_does_not_show_if_no_permissions_for_related_page() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->getJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(404); + } + + public function test_update_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", [ + 'name' => 'My updated image name!', + ]); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $image->id, + 'name' => 'My updated image name!', + ]); + $this->assertDatabaseHas('images', [ + 'id' => $image->id, + 'name' => 'My updated image name!', + ]); + } + + public function test_update_endpoint_requires_image_delete_permission() + { + $user = $this->users->editor(); + $this->actingAsForApi($user); + $imagePage = $this->entities->page(); + $this->permissions->removeUserRolePermissions($user, ['image-update-all', 'image-update-own']); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", ['name' => 'My new name']); + $resp->assertStatus(403); + $resp->assertJson($this->permissionErrorResponse()); + + $this->permissions->grantUserRolePermissions($user, ['image-update-all']); + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", ['name' => 'My new name']); + $resp->assertStatus(200); + } + + public function test_delete_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + $this->assertDatabaseHas('images', ['id' => $image->id]); + + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + + $resp->assertStatus(204); + $this->assertDatabaseMissing('images', ['id' => $image->id]); + } + + public function test_delete_endpoint_requires_image_delete_permission() + { + $user = $this->users->editor(); + $this->actingAsForApi($user); + $imagePage = $this->entities->page(); + $this->permissions->removeUserRolePermissions($user, ['image-delete-all', 'image-delete-own']); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(403); + $resp->assertJson($this->permissionErrorResponse()); + + $this->permissions->grantUserRolePermissions($user, ['image-delete-all']); + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(204); + } +} diff --git a/tests/Api/TestsApi.php b/tests/Api/TestsApi.php index 501f28754..c566fd8de 100644 --- a/tests/Api/TestsApi.php +++ b/tests/Api/TestsApi.php @@ -2,15 +2,28 @@ namespace Tests\Api; +use BookStack\Auth\User; + trait TestsApi { - protected $apiTokenId = 'apitoken'; - protected $apiTokenSecret = 'password'; + protected string $apiTokenId = 'apitoken'; + protected string $apiTokenSecret = 'password'; + + /** + * Set the given user as the current logged-in user via the API driver. + * This does not ensure API access. The user may still lack required role permissions. + */ + protected function actingAsForApi(User $user): static + { + parent::actingAs($user, 'api'); + + return $this; + } /** * Set the API editor role as the current user via the API driver. */ - protected function actingAsApiEditor() + protected function actingAsApiEditor(): static { $this->actingAs($this->users->editor(), 'api'); @@ -20,7 +33,7 @@ trait TestsApi /** * Set the API admin role as the current user via the API driver. */ - protected function actingAsApiAdmin() + protected function actingAsApiAdmin(): static { $this->actingAs($this->users->admin(), 'api');