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);
+ }
+}