From 5ccfdd3a926cd4e91ef9868fecb8430af0c1d587 Mon Sep 17 00:00:00 2001 From: Mazarin Date: Thu, 1 Dec 2022 21:19:50 -0500 Subject: [PATCH] feat: manage vault in api (monicahq/chandler#313) --- .../Api/Controllers/UserController.php | 19 +- .../Api/Controllers/VaultController.php | 128 +++++++++++ app/Http/Controllers/ApiController.php | 23 ++ app/Http/Resources/UserResource.php | 3 + app/Http/Resources/VaultResource.php | 32 +++ app/Models/Contact.php | 6 +- app/Models/ContactInformation.php | 2 +- app/Models/LifeEventCategory.php | 2 +- app/Models/LifeEventType.php | 2 +- app/Models/Post.php | 5 + app/Models/Religion.php | 2 +- app/Models/User.php | 2 +- app/Providers/AppServiceProvider.php | 6 + config/laravelsabre.php | 2 +- phpstan.neon | 10 - routes/api.php | 4 + tests/TestCase.php | 6 +- tests/TestResponseMacros.php | 19 ++ .../Api/Controllers/UserControllerTest.php | 43 +++- .../Api/Controllers/VaultControllerTest.php | 205 ++++++++++++++++++ 20 files changed, 479 insertions(+), 42 deletions(-) create mode 100644 app/Domains/Vault/ManageVault/Api/Controllers/VaultController.php create mode 100644 app/Http/Resources/VaultResource.php create mode 100644 tests/TestResponseMacros.php create mode 100644 tests/Unit/Domains/Vault/ManageVault/Api/Controllers/VaultControllerTest.php diff --git a/app/Domains/Settings/ManageUsers/Api/Controllers/UserController.php b/app/Domains/Settings/ManageUsers/Api/Controllers/UserController.php index cf3d9f666..c3e466b39 100644 --- a/app/Domains/Settings/ManageUsers/Api/Controllers/UserController.php +++ b/app/Domains/Settings/ManageUsers/Api/Controllers/UserController.php @@ -5,10 +5,7 @@ namespace App\Domains\Settings\ManageUsers\Api\Controllers; use App\Http\Controllers\ApiController; use App\Http\Resources\UserResource; use App\Models\User; -use Illuminate\Database\Eloquent\ModelNotFoundException; -use Illuminate\Database\QueryException; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use Knuckles\Scribe\Attributes\QueryParam; use Knuckles\Scribe\Attributes\Response; use Knuckles\Scribe\Attributes\ResponseFromApiResource; @@ -46,12 +43,8 @@ class UserController extends ApiController #[ResponseFromApiResource(UserResource::class, User::class)] public function show(Request $request, int $userId) { - try { - $user = User::where('account_id', Auth::user()->account_id) - ->findOrFail($userId); - } catch (ModelNotFoundException) { - return $this->respondNotFound(); - } + $user = $request->user()->account->users() + ->findOrFail($userId); return new UserResource($user); } @@ -65,12 +58,8 @@ class UserController extends ApiController #[ResponseFromApiResource(UserResource::class, User::class, collection: true)] public function index(Request $request) { - try { - $users = Auth::user()->account->users() - ->paginate($this->getLimitPerPage()); - } catch (QueryException $e) { - return $this->respondInvalidQuery(); - } + $users = $request->user()->account->users() + ->paginate($this->getLimitPerPage()); return UserResource::collection($users); } diff --git a/app/Domains/Vault/ManageVault/Api/Controllers/VaultController.php b/app/Domains/Vault/ManageVault/Api/Controllers/VaultController.php new file mode 100644 index 000000000..0ee2736d4 --- /dev/null +++ b/app/Domains/Vault/ManageVault/Api/Controllers/VaultController.php @@ -0,0 +1,128 @@ +middleware('abilities:read')->only(['index', 'show']); + $this->middleware('abilities:write')->only(['store', 'update', 'delete']); + + parent::__construct(); + } + + /** + * List all vaults + * + * Get all the vaults in the account. + */ + #[QueryParam('limit', 'int', description: 'A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.', required: false, example: 10)] + #[ResponseFromApiResource(VaultResource::class, Vault::class, collection: true)] + public function index(Request $request) + { + $vaults = $request->user()->account->vaults() + ->paginate($this->getLimitPerPage()); + + return VaultResource::collection($vaults); + } + + /** + * Create a vault + * + * Creates a vault object. + */ + #[BodyParam('name', description: 'The name of the vault. Max 255 characters.')] + #[BodyParam('description', description: 'The description of the vault. Max 65535 characters.', required: false)] + #[ResponseFromApiResource(VaultResource::class, Vault::class, status: 201)] + public function store(Request $request) + { + $data = [ + 'account_id' => $request->user()->account_id, + 'author_id' => $request->user()->id, + 'type' => Vault::TYPE_PERSONAL, + 'name' => $request->input('name'), + 'description' => $request->input('description'), + ]; + + $vault = (new CreateVault())->execute($data); + + return new VaultResource($vault); + } + + /** + * Retrieve a vault + * + * Get a specific vault object. + */ + #[ResponseFromApiResource(VaultResource::class, Vault::class)] + public function show(Request $request, int $vaultId) + { + $vault = $request->user()->account->vaults() + ->findOrFail($vaultId); + + return new VaultResource($vault); + } + + /** + * Update a vault + * + * Updates a vault object. + * + * If the call succeeds, the response is the same as the one for the + * Retrieve a vault endpoint. + */ + #[BodyParam('name', description: 'The name of the vault. Max 255 characters.')] + #[BodyParam('description', description: 'The description of the vault. Max 65535 characters.', required: false)] + #[ResponseFromApiResource(VaultResource::class, Vault::class)] + public function update(Request $request, int $vaultId) + { + $data = [ + 'account_id' => $request->user()->account_id, + 'author_id' => $request->user()->id, + 'vault_id' => $vaultId, + 'name' => $request->input('name'), + 'description' => $request->input('description'), + ]; + + $vault = (new UpdateVault())->execute($data); + + return new VaultResource($vault); + } + + /** + * Delete a vault + * + * Destroys a vault object. + * Warning: everything in the vault will be immediately deleted. + */ + #[Response(['deleted' => true, 'id' => 1])] + public function destroy(Request $request, int $vaultId) + { + $data = [ + 'account_id' => $request->user()->account_id, + 'author_id' => $request->user()->id, + 'vault_id' => $vaultId, + ]; + + (new DestroyVault())->execute($data); + + return $this->respondObjectDeleted($vaultId); + } +} diff --git a/app/Http/Controllers/ApiController.php b/app/Http/Controllers/ApiController.php index d5dfda09f..ded0dd10d 100644 --- a/app/Http/Controllers/ApiController.php +++ b/app/Http/Controllers/ApiController.php @@ -4,7 +4,10 @@ namespace App\Http\Controllers; use App\Traits\JsonRespondController; use Closure; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\QueryException; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; class ApiController extends Controller { @@ -40,4 +43,24 @@ class ApiController extends Controller return $this; } + + /** + * Execute an action on the controller. + * + * @param string $method + * @param array $parameters + * @return \Symfony\Component\HttpFoundation\Response + */ + public function callAction($method, $parameters) + { + try { + return $this->{$method}(...array_values($parameters)); + } catch (ModelNotFoundException) { + return $this->respondNotFound(); + } catch (QueryException) { + return $this->respondInvalidQuery(); + } catch (ValidationException $e) { + return $this->respondValidatorFailed($e->validator); + } + } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 2742a0951..97335b02b 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -5,6 +5,9 @@ namespace App\Http\Resources; use App\Helpers\DateHelper; use Illuminate\Http\Resources\Json\JsonResource; +/** + * @mixin \App\Models\User + */ class UserResource extends JsonResource { /** diff --git a/app/Http/Resources/VaultResource.php b/app/Http/Resources/VaultResource.php new file mode 100644 index 000000000..f49ad8bba --- /dev/null +++ b/app/Http/Resources/VaultResource.php @@ -0,0 +1,32 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'created_at' => DateHelper::getTimestamp($this->created_at), + 'updated_at' => DateHelper::getTimestamp($this->updated_at), + 'links' => [ + 'self' => route('api.vaults.show', $this), + ], + ]; + } +} diff --git a/app/Models/Contact.php b/app/Models/Contact.php index 95ad5ac3d..2444ca09f 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -375,7 +375,7 @@ class Contact extends Model /** * Get the name of the contact, according to the user preference. * - * @return Attribute + * @return Attribute */ protected function name(): Attribute { @@ -395,7 +395,7 @@ class Contact extends Model * The birthdate is stored in a ContactImportantDate object, of the * TYPE_BIRTHDATE type. So we need to find if a date of this type exists. * - * @return Attribute + * @return Attribute */ protected function age(): Attribute { @@ -423,7 +423,7 @@ class Contact extends Model /** * Get the avatar of the contact. * - * @return Attribute + * @return Attribute */ protected function avatar(): Attribute { diff --git a/app/Models/ContactInformation.php b/app/Models/ContactInformation.php index 99ffcc507..b34765cb5 100644 --- a/app/Models/ContactInformation.php +++ b/app/Models/ContactInformation.php @@ -50,7 +50,7 @@ class ContactInformation extends Model * If the contact information type is a phone number or an email, return the * content. If it's something else, return the contact information type's label. * - * @return Attribute + * @return Attribute */ protected function name(): Attribute { diff --git a/app/Models/LifeEventCategory.php b/app/Models/LifeEventCategory.php index c69323cbd..6d848905e 100644 --- a/app/Models/LifeEventCategory.php +++ b/app/Models/LifeEventCategory.php @@ -65,7 +65,7 @@ class LifeEventCategory extends Model * Life Event categories have a default label that can be translated. * Howerer, if a label is set, it will be used instead of the default. * - * @return Attribute + * @return Attribute */ protected function label(): Attribute { diff --git a/app/Models/LifeEventType.php b/app/Models/LifeEventType.php index 2ba56d347..4611c25be 100644 --- a/app/Models/LifeEventType.php +++ b/app/Models/LifeEventType.php @@ -146,7 +146,7 @@ class LifeEventType extends Model * Life Event categories have a default label that can be translated. * Howerer, if a label is set, it will be used instead of the default. * - * @return Attribute + * @return Attribute */ protected function label(): Attribute { diff --git a/app/Models/Post.php b/app/Models/Post.php index 1fd1329ab..c982e97e3 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -78,6 +78,11 @@ class Post extends Model return $this->belongsToMany(Tag::class); } + /** + * Get the post's title. + * + * @return Attribute + */ protected function title(): Attribute { return Attribute::make( diff --git a/app/Models/Religion.php b/app/Models/Religion.php index 2848e2494..b9292ce20 100644 --- a/app/Models/Religion.php +++ b/app/Models/Religion.php @@ -38,7 +38,7 @@ class Religion extends Model * The name is either the default name that we get from the translation key, * or the name that the user has entered. * - * @return Attribute + * @return Attribute */ protected function name(): Attribute { diff --git a/app/Models/User.php b/app/Models/User.php index 777e90378..553239c49 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -171,7 +171,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasLocalePreferen /** * Get the name of the user. * - * @return Attribute + * @return Attribute */ protected function name(): Attribute { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7927bc7da..70a8ec925 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,9 +6,12 @@ use App\Http\Controllers\Profile\WebauthnDestroyResponse; use App\Http\Controllers\Profile\WebauthnUpdateResponse; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; +use Illuminate\Testing\TestResponse; use LaravelWebauthn\Facades\Webauthn; +use Tests\TestResponseMacros; class AppServiceProvider extends ServiceProvider { @@ -19,6 +22,9 @@ class AppServiceProvider extends ServiceProvider */ public function register() { + if (App::environment('testing')) { + TestResponse::mixin(new TestResponseMacros); + } } /** diff --git a/config/laravelsabre.php b/config/laravelsabre.php index 8843fb8cf..db59f5e90 100644 --- a/config/laravelsabre.php +++ b/config/laravelsabre.php @@ -52,7 +52,7 @@ return [ 'middleware' => [ 'api', \App\Http\Middleware\EnsureDavRequestsAreStateful::class, - 'abilities:create,read,update,delete', + 'abilities:read,write', ], ]; diff --git a/phpstan.neon b/phpstan.neon index 67efe4491..f8a308016 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,21 +11,11 @@ parameters: inferPrivatePropertyTypeFromConstructor: true level: 5 ignoreErrors: - # psalm/plugin-laravel doesn't support \Illuminate\Database\Eloquent\Casts\Attribute yet - - '#Access to an undefined property App\\Models\\Contact.*::\$name\.#' - - '#Access to an undefined property App\\Http\\Resources\\UserResource::\$[a-zA-Z0-9_]+\.#' - - '#Access to an undefined property App\\Models\\Contact::\$avatar\.#' - - '#Access to an undefined property App\\Models\\Contact::\$age\.#' - '#Access to an undefined property App\\Models\\Post::\$excerpt\.#' - '#Access to an undefined property App\\Models\\Post::\$year\.#' - '#Access to an undefined property App\\Models\\Label::\$contacts_count\.#' - '#Access to an undefined property App\\Models\\Tag::\$posts_count\.#' - - '#Access to an undefined property App\\Models\\LifeEventCategory.*::\$label\.#' - - '#Access to an undefined property App\\Models\\LifeEventType.*::\$label.#' - '#Access to an undefined property App\\Models\\Module::\$position\.#' - - '#Access to an undefined property App\\Models\\Post::\$title\.#' - - '#Access to an undefined property App\\Models\\Religion::\$name\.#' - - '#Access to an undefined property App\\Models\\User::\$name\.#' - '#Access to an undefined property App\\Models\\.*::\$pivot\.#' - '#Access to an undefined property Sabre\\VObject\\Component\\VCard::\$.*\.#' diff --git a/routes/api.php b/routes/api.php index 9493d1953..6f0cad583 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,10 +1,14 @@ name('api.')->group(function () { // users Route::get('user', [UserController::class, 'user']); Route::apiResource('users', UserController::class)->only(['index', 'show']); + + // vaults + Route::apiResource('vaults', VaultController::class); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 818d2f6dc..e525c13e7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -19,10 +19,10 @@ abstract class TestCase extends BaseTestCase * * @return User */ - public function createUser(): User + public function createUser($abilities = ['read', 'write']): User { - return tap(User::factory()->create(), function (User $user) { - Sanctum::actingAs($user, ['*']); + return tap(User::factory()->create(), function (User $user) use ($abilities) { + Sanctum::actingAs($user, $abilities); }); } diff --git a/tests/TestResponseMacros.php b/tests/TestResponseMacros.php new file mode 100644 index 000000000..9c4f8bc4c --- /dev/null +++ b/tests/TestResponseMacros.php @@ -0,0 +1,19 @@ +assertStatus(404); + $this->assertExactJson([ + 'error' => [ + 'error_code' => 31, + 'message' => 'The resource has not been found', + ], + ]); + }; + } +} diff --git a/tests/Unit/Domains/Settings/ManageUsers/Api/Controllers/UserControllerTest.php b/tests/Unit/Domains/Settings/ManageUsers/Api/Controllers/UserControllerTest.php index 60a6cfb63..136cd3ca8 100644 --- a/tests/Unit/Domains/Settings/ManageUsers/Api/Controllers/UserControllerTest.php +++ b/tests/Unit/Domains/Settings/ManageUsers/Api/Controllers/UserControllerTest.php @@ -2,8 +2,8 @@ namespace Tests\Unit\Domains\Settings\ManageUsers\Api\Controllers; +use App\Models\User; use Carbon\Carbon; -use Laravel\Sanctum\Sanctum; use Tests\ApiTestCase; class UserControllerTest extends ApiTestCase @@ -12,8 +12,7 @@ class UserControllerTest extends ApiTestCase public function it_gets_the_current_user(): void { Carbon::setTestNow(Carbon::create(2018, 1, 1)); - $user = $this->createUser(); - Sanctum::actingAs($user, ['read']); + $user = $this->createUser(['read']); $response = $this->get('/api/user'); @@ -36,8 +35,7 @@ class UserControllerTest extends ApiTestCase public function it_gets_a_list_of_users(): void { Carbon::setTestNow(Carbon::create(2018, 1, 1)); - $user = $this->createUser(); - Sanctum::actingAs($user, ['read']); + $user = $this->createUser(['read']); $response = $this->get('/api/users'); @@ -59,4 +57,39 @@ class UserControllerTest extends ApiTestCase 'meta' => $this->meta('/api/users'), ]); } + + /** @test */ + public function it_gets_a_user_details(): void + { + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $user = $this->createUser(['read']); + + $response = $this->get('/api/users/'.$user->id); + + $response->assertStatus(200); + $response->assertExactJson([ + 'data' => [ + 'id' => $user->id, + 'name' => $user->first_name.' '.$user->last_name, + 'email' => $user->email, + 'created_at' => '2018-01-01T00:00:00Z', + 'updated_at' => '2018-01-01T00:00:00Z', + 'links' => [ + 'self' => env('APP_URL')."/api/users/{$user->id}", + ], + ], + ]); + } + + /** @test */ + public function it_gets_an_exception_getting_unexisting_user(): void + { + $this->createUser(['read']); + + $user = User::factory()->create(); + + $response = $this->get('/api/vaults/'.$user->id); + + $response->assertResourceNotFound(); + } } diff --git a/tests/Unit/Domains/Vault/ManageVault/Api/Controllers/VaultControllerTest.php b/tests/Unit/Domains/Vault/ManageVault/Api/Controllers/VaultControllerTest.php new file mode 100644 index 000000000..e10343d26 --- /dev/null +++ b/tests/Unit/Domains/Vault/ManageVault/Api/Controllers/VaultControllerTest.php @@ -0,0 +1,205 @@ +createUser(['read']); + + $vault = Vault::factory()->create([ + 'account_id' => $user->account_id, + 'name' => 'This is a vault', + 'description' => 'this is a description', + ]); + + $response = $this->get('/api/vaults'); + + $response->assertStatus(200); + $response->assertExactJson([ + 'data' => [ + 0 => [ + 'id' => $vault->id, + 'name' => 'This is a vault', + 'description' => 'this is a description', + 'created_at' => '2018-01-01T00:00:00Z', + 'updated_at' => '2018-01-01T00:00:00Z', + 'links' => [ + 'self' => env('APP_URL').'/api/vaults/'.$vault->id, + ], + ], + ], + 'links' => $this->links('/api/vaults'), + 'meta' => $this->meta('/api/vaults'), + ]); + } + + /** @test */ + public function it_stores_a_vault(): void + { + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $this->createUser(['write']); + + $form = [ + 'name' => 'this is a name', + 'description' => 'this is a description', + ]; + $response = $this->post('/api/vaults', $form); + $response->assertStatus(201); + + $vault = Vault::latest()->first(); + $response->assertExactJson([ + 'data' => [ + 'id' => $vault->id, + 'name' => 'this is a name', + 'description' => 'this is a description', + 'created_at' => '2018-01-01T00:00:00Z', + 'updated_at' => '2018-01-01T00:00:00Z', + 'links' => [ + 'self' => env('APP_URL').'/api/vaults/'.$vault->id, + ], + ], + ]); + } + + /** @test */ + public function it_gets_a_vault(): void + { + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $user = $this->createUser(['read']); + + $vault = Vault::factory()->create([ + 'account_id' => $user->account_id, + 'name' => 'This is a vault', + 'description' => 'this is a description', + ]); + + $response = $this->get('/api/vaults/'.$vault->id); + + $response->assertStatus(200); + $response->assertExactJson([ + 'data' => [ + 'id' => $vault->id, + 'name' => 'This is a vault', + 'description' => 'this is a description', + 'created_at' => '2018-01-01T00:00:00Z', + 'updated_at' => '2018-01-01T00:00:00Z', + 'links' => [ + 'self' => env('APP_URL').'/api/vaults/'.$vault->id, + ], + ], + ]); + } + + /** @test */ + public function it_gets_an_exception_getting_unexisting_vault(): void + { + $this->createUser(['read']); + + $vault = Vault::factory()->create(); + + $response = $this->get('/api/vaults/'.$vault->id); + + $response->assertResourceNotFound(); + } + + /** @test */ + public function it_updates_a_vault(): void + { + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $user = $this->createUser(['write']); + + $vault = Vault::factory()->create([ + 'account_id' => $user->account_id, + 'name' => 'This is a vault', + 'description' => 'this is a description', + ]); + $vault = $this->setPermissionInVault($user, Vault::PERMISSION_MANAGE, $vault); + + $form = [ + 'account_id' => $user->account_id, + 'author_id' => $user->id, + 'vault_id' => $vault->id, + 'name' => 'this is a name', + 'description' => 'this is a cool description', + ]; + + Carbon::setTestNow(Carbon::create(2020, 1, 1)); + + $response = $this->put('/api/vaults/'.$vault->id, $form); + $response->assertStatus(200); + + $response->assertExactJson([ + 'data' => [ + 'id' => $vault->id, + 'name' => 'this is a name', + 'description' => 'this is a cool description', + 'created_at' => '2018-01-01T00:00:00Z', + 'updated_at' => '2020-01-01T00:00:00Z', + 'links' => [ + 'self' => env('APP_URL').'/api/vaults/'.$vault->id, + ], + ], + ]); + } + + /** @test */ + public function it_gets_an_exception_updating_unexisting_vault(): void + { + $this->createUser(['write']); + + $vault = Vault::factory()->create(); + + $response = $this->put('/api/vaults/'.$vault->id, [ + 'name' => 'this is a name', + 'description' => 'this is a cool description', + ]); + + $response->assertResourceNotFound(); + } + + /** @test */ + public function it_destroys_a_vault(): void + { + Carbon::setTestNow(Carbon::create(2018, 1, 1)); + $user = $this->createUser(['write']); + + $vault = Vault::factory()->create([ + 'account_id' => $user->account_id, + ]); + $vault = $this->setPermissionInVault($user, Vault::PERMISSION_MANAGE, $vault); + + $form = [ + 'account_id' => $user->account_id, + 'author_id' => $user->id, + 'vault_id' => $vault->id, + ]; + + $response = $this->delete('/api/vaults/'.$vault->id, $form); + $response->assertStatus(200); + + $response->assertExactJson([ + 'deleted' => true, + 'id' => $vault->id, + ]); + } + + /** @test */ + public function it_gets_an_exception_deleting_unexisting_vault(): void + { + $this->createUser(['write']); + + $vault = Vault::factory()->create(); + + $response = $this->delete('/api/vaults/'.$vault->id); + + $response->assertResourceNotFound(); + } +}