diff --git a/CHANGELOG.md b/CHANGELOG.md index 902788c..6becc64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v1.5 ---- +- Fix FLEXIAPI-164 Add vcards-storage endpoints - Fix FLEXIAPI-162 Drop the aliases table and migrate the data to the phone column - Fix FLEXIAPI-161 Complete the Dictionary tests to cover the collection accessor - Fix FLEXIAPI-158 Restrict the phone number change API endpoint to return 403 if the account doesn't have a validated Account Creation Token diff --git a/flexiapi/app/Account.php b/flexiapi/app/Account.php index 55fafb5..a70d554 100644 --- a/flexiapi/app/Account.php +++ b/flexiapi/app/Account.php @@ -130,6 +130,11 @@ class Account extends Authenticatable return $this->belongsToMany(Account::class, 'contacts', 'account_id', 'contact_id'); } + public function vcardsStorage() + { + return $this->hasMany(VcardStorage::class); + } + public function contactsLists() { return $this->belongsToMany(ContactsList::class, 'account_contacts_list', 'account_id', 'contacts_list_id'); diff --git a/flexiapi/app/Http/Controllers/Account/VcardsStorageController.php b/flexiapi/app/Http/Controllers/Account/VcardsStorageController.php new file mode 100644 index 0000000..b9f7706 --- /dev/null +++ b/flexiapi/app/Http/Controllers/Account/VcardsStorageController.php @@ -0,0 +1,37 @@ +vcardRequested($request)) { + $vcards = ''; + + foreach ($request->user()->vcardsStorage()->get() as $vcard) { + $vcards .= $vcard->vcard . "\n"; + } + + return $vcards; + } + + abort(404); + } + + public function show(Request $request, string $uuid) + { + return ($this->vcardRequested($request)) + ? $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail()->vcard + : abort(404); + } + + private function vcardRequested(Request $request): bool + { + return $request->hasHeader('content-type') == 'text/vcard' + && $request->hasHeader('accept') == 'text/vcard'; + } +} diff --git a/flexiapi/app/Http/Controllers/Api/Account/VcardsStorageController.php b/flexiapi/app/Http/Controllers/Api/Account/VcardsStorageController.php new file mode 100644 index 0000000..4107764 --- /dev/null +++ b/flexiapi/app/Http/Controllers/Api/Account/VcardsStorageController.php @@ -0,0 +1,70 @@ +user()->vcardsStorage()->get()->keyBy('uuid'); + } + + public function show(Request $request, string $uuid) + { + return $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail(); + } + + public function store(Request $request) + { + $request->validate([ + 'vcard' => ['required', new Vcard()] + ]); + + $vcardo = VObject\Reader::read($request->get('vcard')); + + if ($request->user()->vcardsStorage()->where('uuid', $vcardo->UID)->first()) { + abort(409, 'Vcard already exists'); + } + + $vcard = new VcardStorage(); + $vcard->account_id = $request->user()->id; + $vcard->uuid = $vcardo->UID; + $vcard->vcard = preg_replace('/\r\n?/', "\n", $vcardo->serialize()); + $vcard->save(); + + return $vcard->vcard; + } + + public function update(Request $request, string $uuid) + { + $request->validate([ + 'vcard' => ['required', new Vcard()] + ]); + + $vcardo = VObject\Reader::read($request->get('vcard')); + + if ($vcardo->UID != $uuid) { + abort(422, 'UUID should be the same'); + } + + $vcard = $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail(); + $vcard->vcard = preg_replace('/\r\n?/', "\n", $vcardo->serialize()); + $vcard->save(); + + return $vcard->vcard; + } + + public function destroy(Request $request, string $uuid) + { + $vcard = $request->user()->vcardsStorage()->where('uuid', $uuid)->firstOrFail(); + + return $vcard->delete(); + } +} diff --git a/flexiapi/app/Rules/Vcard.php b/flexiapi/app/Rules/Vcard.php new file mode 100644 index 0000000..5832282 --- /dev/null +++ b/flexiapi/app/Rules/Vcard.php @@ -0,0 +1,35 @@ +validate())) return false; + if ($vcard->UID == null) return false; + + return true; + } catch (\Throwable $th) { + return false; + } + } + + public function message() + { + return 'Invalid vcard passed'; + } +} diff --git a/flexiapi/app/VcardStorage.php b/flexiapi/app/VcardStorage.php new file mode 100644 index 0000000..2e1b8e5 --- /dev/null +++ b/flexiapi/app/VcardStorage.php @@ -0,0 +1,20 @@ +belongsTo(Account::class); + } +} diff --git a/flexiapi/composer.json b/flexiapi/composer.json index 5a2f4b0..daacf3a 100644 --- a/flexiapi/composer.json +++ b/flexiapi/composer.json @@ -22,6 +22,7 @@ "phpunit/phpunit": "^9.6", "react/socket": "^1.14", "respect/validation": "^2.2", + "sabre/vobject": "^4.5", "scyllaly/hcaptcha": "^4.4" }, "require-dev": { diff --git a/flexiapi/composer.lock b/flexiapi/composer.lock index e363974..abfc521 100644 --- a/flexiapi/composer.lock +++ b/flexiapi/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "daf1c028cc9a6eb9b5d28ba1bd25cec5", + "content-hash": "e9d79f33a899fd2dcde3de88c243e800", "packages": [ { "name": "awobaz/compoships", @@ -2716,16 +2716,16 @@ }, { "name": "monolog/monolog", - "version": "2.9.2", + "version": "2.9.3", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f" + "reference": "a30bfe2e142720dfa990d0a7e573997f5d884215" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/437cb3628f4cf6042cc10ae97fc2b8472e48ca1f", - "reference": "437cb3628f4cf6042cc10ae97fc2b8472e48ca1f", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/a30bfe2e142720dfa990d0a7e573997f5d884215", + "reference": "a30bfe2e142720dfa990d0a7e573997f5d884215", "shasum": "" }, "require": { @@ -2746,8 +2746,8 @@ "mongodb/mongodb": "^1.8", "php-amqplib/php-amqplib": "~2.4 || ^3", "phpspec/prophecy": "^1.15", - "phpstan/phpstan": "^0.12.91", - "phpunit/phpunit": "^8.5.14", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.38 || ^9.6.19", "predis/predis": "^1.1 || ^2.0", "rollbar/rollbar": "^1.3 || ^2 || ^3", "ruflin/elastica": "^7", @@ -2802,7 +2802,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.9.2" + "source": "https://github.com/Seldaek/monolog/tree/2.9.3" }, "funding": [ { @@ -2814,7 +2814,7 @@ "type": "tidelift" } ], - "time": "2023-10-27T15:25:26+00:00" + "time": "2024-04-12T20:52:51+00:00" }, { "name": "myclabs/deep-copy", @@ -5400,6 +5400,239 @@ }, "time": "2023-02-15T01:05:24+00:00" }, + { + "name": "sabre/uri", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/uri.git", + "reference": "1774043c843f1db7654ecc93368a98be29b07544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/1774043c843f1db7654ecc93368a98be29b07544", + "reference": "1774043c843f1db7654ecc93368a98be29b07544", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.17", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-strict-rules": "^1.5", + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Sabre\\Uri\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + } + ], + "description": "Functions for making sense out of URIs.", + "homepage": "http://sabre.io/uri/", + "keywords": [ + "rfc3986", + "uri", + "url" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/uri/issues", + "source": "https://github.com/fruux/sabre-uri" + }, + "time": "2023-06-09T07:04:02+00:00" + }, + { + "name": "sabre/vobject", + "version": "4.5.4", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/vobject.git", + "reference": "a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772", + "reference": "a6d53a3e5bec85ed3dd78868b7de0f5b4e12f772", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabre/xml": "^2.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.17.1", + "phpstan/phpstan": "^0.12", + "phpunit/php-invoker": "^2.0 || ^3.1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.0" + }, + "suggest": { + "hoa/bench": "If you would like to run the benchmark scripts" + }, + "bin": [ + "bin/vobject", + "bin/generate_vcards" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabre\\VObject\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Dominik Tobschall", + "email": "dominik@fruux.com", + "homepage": "http://tobschall.de/", + "role": "Developer" + }, + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net", + "homepage": "http://mnt.io/", + "role": "Developer" + } + ], + "description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects", + "homepage": "http://sabre.io/vobject/", + "keywords": [ + "availability", + "freebusy", + "iCalendar", + "ical", + "ics", + "jCal", + "jCard", + "recurrence", + "rfc2425", + "rfc2426", + "rfc2739", + "rfc4770", + "rfc5545", + "rfc5546", + "rfc6321", + "rfc6350", + "rfc6351", + "rfc6474", + "rfc6638", + "rfc6715", + "rfc6868", + "vCalendar", + "vCard", + "vcf", + "xCal", + "xCard" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/vobject/issues", + "source": "https://github.com/fruux/sabre-vobject" + }, + "time": "2023-11-09T12:54:37+00:00" + }, + { + "name": "sabre/xml", + "version": "4.0.5", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/xml.git", + "reference": "c29e49fcf9ca8ca058b1e350ee9abe4205c0de89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/c29e49fcf9ca8ca058b1e350ee9abe4205c0de89", + "reference": "c29e49fcf9ca8ca058b1e350ee9abe4205c0de89", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "lib-libxml": ">=2.6.20", + "php": "^7.4 || ^8.0", + "sabre/uri": ">=2.0,<4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.51", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Deserializer/functions.php", + "lib/Serializer/functions.php" + ], + "psr-4": { + "Sabre\\Xml\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Markus Staab", + "email": "markus.staab@redaxo.de", + "role": "Developer" + } + ], + "description": "sabre/xml is an XML library that you may not hate.", + "homepage": "https://sabre.io/xml/", + "keywords": [ + "XMLReader", + "XMLWriter", + "dom", + "xml" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/xml/issues", + "source": "https://github.com/fruux/sabre-xml" + }, + "time": "2024-04-18T10:44:25+00:00" + }, { "name": "scyllaly/hcaptcha", "version": "4.4.5", @@ -9014,16 +9247,16 @@ "packages-dev": [ { "name": "barryvdh/laravel-debugbar", - "version": "v3.13.3", + "version": "v3.13.4", "source": { "type": "git", "url": "https://github.com/barryvdh/laravel-debugbar.git", - "reference": "241e9bddb04ab42a04a5fe8b2b9654374c864229" + "reference": "00201bcd1eaf9b1d3debddcdc13c219e4835fb61" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/241e9bddb04ab42a04a5fe8b2b9654374c864229", - "reference": "241e9bddb04ab42a04a5fe8b2b9654374c864229", + "url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/00201bcd1eaf9b1d3debddcdc13c219e4835fb61", + "reference": "00201bcd1eaf9b1d3debddcdc13c219e4835fb61", "shasum": "" }, "require": { @@ -9082,7 +9315,7 @@ ], "support": { "issues": "https://github.com/barryvdh/laravel-debugbar/issues", - "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.13.3" + "source": "https://github.com/barryvdh/laravel-debugbar/tree/v3.13.4" }, "funding": [ { @@ -9094,7 +9327,7 @@ "type": "github" } ], - "time": "2024-04-04T02:42:49+00:00" + "time": "2024-04-10T09:15:45+00:00" }, { "name": "composer/pcre", diff --git a/flexiapi/database/migrations/2024_04_22_141226_vcards_storage.php b/flexiapi/database/migrations/2024_04_22_141226_vcards_storage.php new file mode 100644 index 0000000..e8d15e2 --- /dev/null +++ b/flexiapi/database/migrations/2024_04_22_141226_vcards_storage.php @@ -0,0 +1,29 @@ +id(); + $table->uuid('uuid')->index(); + $table->text('vcard'); + + $table->integer('account_id')->unsigned(); + $table->foreign('account_id')->references('id') + ->on('accounts')->onDelete('cascade'); + + $table->unique(['account_id', 'uuid']); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('vcards_storage'); + } +}; diff --git a/flexiapi/resources/views/api/documentation_markdown.blade.php b/flexiapi/resources/views/api/documentation_markdown.blade.php index c2e6fac..3c28ce1 100644 --- a/flexiapi/resources/views/api/documentation_markdown.blade.php +++ b/flexiapi/resources/views/api/documentation_markdown.blade.php @@ -436,6 +436,41 @@ Return the user contacts. Return a user contact. +## Account vCards storage + +### `POST /accounts/me/vcards-storage` +User + +Store a vCard. + +JSON parameters: + +* `vcard`, mandatory, a valid vCard having a mandatory `UID` parameter that is uniquelly identifying it. This `UID` parameter will then be used to manipulate the vcard through the following endpoints as `uuid`. + +### `PUT /accounts/me/vcards-storage/{uuid}` +User + +Update a vCard. + +JSON parameters: + +* `vcard`, mandatory, a valid vCard having a mandatory `UID` parameter that is uniquelly identifying it and is the same as the `uuid` parameter. + +### `GET /accounts/me/vcards-storage` +User + +Return the list of stored vCards + +### `GET /accounts/me/vcards-storage/{uuid}` +User + +Return a stored vCard + +### `DELETE /accounts/me/vcards-storage/{uuid}` +User + +Delete a stored vCard + ## Contacts ### `GET /accounts/{id}/contacts` @@ -724,4 +759,24 @@ END:VCARD ### `GET /contacts/vcard/{sip}` User -Return a specific user authenticated contact, in [vCard 4.0 format](https://datatracker.ietf.org/doc/html/rfc6350). \ No newline at end of file +Return a specific user authenticated contact, in [vCard 4.0 format](https://datatracker.ietf.org/doc/html/rfc6350). + +## vCards Storage + +The following headers are mandatory to access the following endpoints: + +``` +> content-type: text/vcard +> accept: text/vcard +``` + +### `GET /vcards-storage` + +User + +Return the list of stored vCards + +### `GET /vcards-storage/{uuid}` +User + +Return a stored vCard diff --git a/flexiapi/routes/api.php b/flexiapi/routes/api.php index b94b363..8918f1b 100644 --- a/flexiapi/routes/api.php +++ b/flexiapi/routes/api.php @@ -17,6 +17,7 @@ along with this program. If not, see . */ +use App\Http\Controllers\Api\Account\VcardsStorageController; use App\Http\Controllers\Api\Admin\AccountActionController; use App\Http\Controllers\Api\Admin\AccountContactController; use App\Http\Controllers\Api\Admin\AccountController as AdminAccountController; @@ -78,6 +79,8 @@ Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key', 'auth.check_blo Route::get('contacts/{sip}', 'Api\Account\ContactController@show'); Route::get('contacts', 'Api\Account\ContactController@index'); + + Route::apiResource('vcards-storage', VcardsStorageController::class); }); Route::group(['middleware' => ['auth.admin']], function () { diff --git a/flexiapi/routes/web.php b/flexiapi/routes/web.php index f4b553d..0f1c5ee 100644 --- a/flexiapi/routes/web.php +++ b/flexiapi/routes/web.php @@ -39,7 +39,6 @@ use App\Http\Controllers\Admin\AccountStatisticsController; use App\Http\Controllers\Admin\ContactsListController; use App\Http\Controllers\Admin\ContactsListContactController; use App\Http\Controllers\Admin\StatisticsController; -use Laravel\Socialite\Facades\Socialite; use Illuminate\Support\Facades\Route; Route::redirect('/', 'login')->name('account.home'); @@ -60,9 +59,13 @@ Route::group(['middleware' => 'web_panel_enabled'], function () { Route::group(['middleware' => ['auth.jwt', 'auth.digest_or_key']], function () { Route::get('provisioning/me', 'Account\ProvisioningController@me')->name('provisioning.me'); - // Vcard 4.0 + // vCard 4.0 Route::get('contacts/vcard/{sip}', 'Account\ContactVcardController@show')->name('account.contacts.vcard.show'); Route::get('contacts/vcard', 'Account\ContactVcardController@index')->name('account.contacts.vcard.index'); + + // vCards Storage + Route::get('vcards-storage/{uuid}', 'Account\VcardsStorageController@show')->name('account.vcards-storage.show'); + Route::get('vcards-storage/', 'Account\VcardsStorageController@index')->name('account.vcards-storage.index'); }); Route::name('provisioning.')->prefix('provisioning')->controller(ProvisioningController::class)->group(function () { diff --git a/flexiapi/tests/Feature/ApiAccountVcardsStorageTest.php b/flexiapi/tests/Feature/ApiAccountVcardsStorageTest.php new file mode 100644 index 0000000..7e1df75 --- /dev/null +++ b/flexiapi/tests/Feature/ApiAccountVcardsStorageTest.php @@ -0,0 +1,171 @@ +. +*/ + +namespace Tests\Feature; + +use App\Account; +use Tests\TestCase; + +class ApiAccountVcardsStorageTest extends TestCase +{ + protected $route = '/api/accounts/me/vcards-storage'; + protected $method = 'POST'; + + public function testCrud() + { + $account = Account::factory()->create(); + $account->generateApiKey(); + + $uid = 'urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6'; + $lastVcard = +'BEGIN:VCARD +VERSION:4.0 +FN:Simone Perreault +UID:' . $uid . ' +END:VCARD +'; + $uid2 = 'urn:uuid: a5b33443-687c-4d19-bdd0-b30cf76bf96d'; + $secondVcard = +'BEGIN:VCARD +VERSION:4.0 +FN:Simone Perreault +UID:' . $uid2 . ' +END:VCARD +'; + + // Missing vcard + $this->keyAuthenticated($account) + ->json($this->method, $this->route, [ + 'foo' => 'bar' + ]) + ->assertJsonValidationErrors(['vcard']); + + // Missing UID + $this->keyAuthenticated($account) + ->json($this->method, $this->route, [ + 'vcard' => +'BEGIN:VCARD +VERSION:4.0 +FN:Simon Perreault +END:VCARD' + ])->assertJsonValidationErrors(['vcard']); + + // Create + $this->keyAuthenticated($account) + ->json($this->method, $this->route, [ + 'vcard' => +'BEGIN:VCARD +VERSION:4.0 +FN:Simon Perreault +UID:' . $uid . ' +END:VCARD' + ])->assertStatus(200); + + // Again... + $this->keyAuthenticated($account) + ->json($this->method, $this->route, [ + 'vcard' => +'BEGIN:VCARD +VERSION:4.0 +FN:Simon Perreault +UID:' . $uid . ' +END:VCARD' + ])->assertStatus(409); + + $this->keyAuthenticated($account) + ->json($this->method, $this->route, [ + 'vcard' => $secondVcard + ])->assertStatus(200); + + $this->assertDatabaseHas('vcards_storage', [ + 'uuid' => $uid + ]); + + $this->assertDatabaseHas('vcards_storage', [ + 'uuid' => $uid2 + ]); + + // Update + $this->keyAuthenticated($account) + ->json('PUT', $this->route . '/' . $uid, [ + 'vcard' => $lastVcard + ])->assertStatus(200); + + // Update with wrong UID + $this->keyAuthenticated($account) + ->json('PUT', $this->route . '/' . $uid, [ + 'vcard' => +'BEGIN:VCARD +VERSION:4.0 +FN:Simone Perreault +UID:123 +END:VCARD' + ])->assertStatus(422); + + // Index + $this->keyAuthenticated($account) + ->get($this->route) + ->assertStatus(200) + ->assertJson([ + $uid => ['vcard' => $lastVcard], + $uid2 => ['vcard' => $secondVcard] + ]); + + // Get + $this->keyAuthenticated($account) + ->get($this->route . '/' . $uid) + ->assertStatus(200) + ->assertJson([ + 'vcard' => $lastVcard + ]); + + // Vcard format endpoints + $this->keyAuthenticated($account) + ->get('vcards-storage') + ->assertStatus(404); + + $this->keyAuthenticated($account) + ->withHeaders([ + 'content-type' => 'text/vcard', + 'accept' => 'text/vcard', + ]) + ->get('vcards-storage') + ->assertStatus(200) + ->assertSee($lastVcard) + ->assertSee($secondVcard); + + $this->keyAuthenticated($account) + ->withHeaders([ + 'content-type' => 'text/vcard', + 'accept' => 'text/vcard', + ]) + ->get('vcards-storage/' . $uid) + ->assertStatus(200) + ->assertSee($lastVcard); + + // Delete + $this->keyAuthenticated($account) + ->delete($this->route . '/' . $uid) + ->assertStatus(200); + + $this->keyAuthenticated($account) + ->get($this->route . '/' . $uid) + ->assertStatus(404); + } +}