diff --git a/app/Http/Controllers/Articles/ArticlesController.php b/app/Http/Controllers/Articles/ArticlesController.php index 89db0caa8..4af1e3247 100644 --- a/app/Http/Controllers/Articles/ArticlesController.php +++ b/app/Http/Controllers/Articles/ArticlesController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Middleware\Authenticate; use App\Http\Requests\ArticleRequest; +use App\Http\Resources\ArticleResource; use App\Jobs\CreateArticle; use App\Jobs\DeleteArticle; use App\Jobs\UpdateArticle; @@ -17,6 +18,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Symfony\Component\HttpFoundation\Response; class ArticlesController extends Controller { @@ -101,11 +103,13 @@ public function create() public function store(ArticleRequest $request) { - $article = $this->dispatchNow(CreateArticle::fromRequest($request)); + $article = $this->dispatchSync(CreateArticle::fromRequest($request)); $this->success($request->shouldBeSubmitted() ? 'articles.submitted' : 'articles.created'); - return redirect()->route('articles.show', $article->slug()); + return $request->wantsJson() + ? ArticleResource::make($article) + : redirect()->route('articles.show', $article->slug()); } public function edit(Article $article) @@ -125,7 +129,7 @@ public function update(ArticleRequest $request, Article $article) $wasNotPreviouslySubmitted = $article->isNotSubmitted(); - $article = $this->dispatchNow(UpdateArticle::fromRequest($article, $request)); + $article = $this->dispatchSync(UpdateArticle::fromRequest($article, $request)); if ($wasNotPreviouslySubmitted && $request->shouldBeSubmitted()) { $this->success('articles.submitted'); @@ -133,17 +137,21 @@ public function update(ArticleRequest $request, Article $article) $this->success('articles.updated'); } - return redirect()->route('articles.show', $article->slug()); + return $request->wantsJson() + ? ArticleResource::make($article) + : redirect()->route('articles.show', $article->slug()); } - public function delete(Article $article) + public function delete(Request $request, Article $article) { $this->authorize(ArticlePolicy::DELETE, $article); - $this->dispatchNow(new DeleteArticle($article)); + $this->dispatchSync(new DeleteArticle($article)); $this->success('articles.deleted'); - return redirect()->route('articles'); + return $request->wantsJson() + ? response()->json([], Response::HTTP_NO_CONTENT) + : redirect()->route('articles'); } } diff --git a/app/Http/Controllers/Settings/ApiTokenController.php b/app/Http/Controllers/Settings/ApiTokenController.php new file mode 100644 index 000000000..41c4fc82e --- /dev/null +++ b/app/Http/Controllers/Settings/ApiTokenController.php @@ -0,0 +1,33 @@ +middleware(Authenticate::class); + } + + public function store(CreateApiTokenRequest $request) + { + $this->dispatchSync(new CreateApiToken(Auth::user(), $request->name())); + + return redirect()->route('settings.profile'); + } + + public function destroy(DeleteApiTokenRequest $request) + { + $this->dispatchSync(new DeleteApiToken(Auth::user(), $request->id())); + + return redirect()->route('settings.profile'); + } +} diff --git a/app/Http/Requests/CreateApiTokenRequest.php b/app/Http/Requests/CreateApiTokenRequest.php new file mode 100644 index 000000000..d22758d75 --- /dev/null +++ b/app/Http/Requests/CreateApiTokenRequest.php @@ -0,0 +1,20 @@ + ['required', 'string', 'max:255'], + ]; + } + + public function name(): string + { + return (string) $this->get('name'); + } +} diff --git a/app/Http/Requests/DeleteApiTokenRequest.php b/app/Http/Requests/DeleteApiTokenRequest.php new file mode 100644 index 000000000..6bedf2630 --- /dev/null +++ b/app/Http/Requests/DeleteApiTokenRequest.php @@ -0,0 +1,20 @@ + ['required', 'exists:personal_access_tokens,id'], + ]; + } + + public function id(): string + { + return (string) $this->get('id'); + } +} diff --git a/app/Http/Resources/ArticleResource.php b/app/Http/Resources/ArticleResource.php new file mode 100644 index 000000000..65fecd054 --- /dev/null +++ b/app/Http/Resources/ArticleResource.php @@ -0,0 +1,28 @@ + $this->getKey(), + 'url' => route('articles.show', $this->slug()), + 'title' => $this->title(), + 'body' => $this->body(), + 'original_url' => $this->originalUrl(), + 'author' => AuthorResource::make($this->author()), + 'tags' => TagResource::collection($this->tags()), + 'is_submitted' => $this->isSubmitted(), + 'submitted_at' => $this->submittedAt(), + 'created_at' => $this->createdAt(), + 'updated_at' => $this->updatedAt(), + ]; + } +} diff --git a/app/Http/Resources/AuthorResource.php b/app/Http/Resources/AuthorResource.php new file mode 100644 index 000000000..23a1e27e2 --- /dev/null +++ b/app/Http/Resources/AuthorResource.php @@ -0,0 +1,24 @@ + $this->getKey(), + 'email' => $this->emailAddress(), + 'username' => $this->username(), + 'name' => $this->name(), + 'bio' => $this->bio(), + 'twitter_handle' => $this->twitter(), + 'github_username' => $this->githubUsername(), + ]; + } +} diff --git a/app/Http/Resources/TagResource.php b/app/Http/Resources/TagResource.php new file mode 100644 index 000000000..bd44f506b --- /dev/null +++ b/app/Http/Resources/TagResource.php @@ -0,0 +1,20 @@ + $this->getKey(), + 'name' => $this->name(), + 'slug' => $this->slug(), + ]; + } +} diff --git a/app/Jobs/CreateApiToken.php b/app/Jobs/CreateApiToken.php new file mode 100644 index 000000000..22b6b0712 --- /dev/null +++ b/app/Jobs/CreateApiToken.php @@ -0,0 +1,24 @@ +user->createToken($this->name); + } +} diff --git a/app/Jobs/DeleteApiToken.php b/app/Jobs/DeleteApiToken.php new file mode 100644 index 000000000..4da916d16 --- /dev/null +++ b/app/Jobs/DeleteApiToken.php @@ -0,0 +1,24 @@ +user->tokens()->where('id', $this->tokenId)->delete(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 743faff14..58c66665b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,9 +10,11 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Auth; +use Laravel\Sanctum\HasApiTokens; final class User extends Authenticatable implements MustVerifyEmail { + use HasApiTokens; use HasFactory; use HasTimestamps; use Notifiable; diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 62fc9d3ea..7b314387c 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; +use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; @@ -49,7 +50,7 @@ public function boot() protected function configureRateLimiting() { RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60); + return Limit::perMinute(6); }); } } diff --git a/composer.json b/composer.json index f37e7aa4d..5be8d7dee 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "laravel-notification-channels/twitter": "^5.0", "laravel/framework": "8.75.0", "laravel/horizon": "^5.2", + "laravel/sanctum": "^2.13", "laravel/slack-notification-channel": "^2.3", "laravel/socialite": "^5.0", "laravel/ui": "^3.0", diff --git a/composer.lock b/composer.lock index 1e54b0903..0ebbe3b0c 100644 --- a/composer.lock +++ b/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": "9b314c81b08fdbcdb208b7cc8723abe6", + "content-hash": "ab818bb4925f4b7c34eb0449e5cce022", "packages": [ { "name": "abraham/twitteroauth", @@ -2844,6 +2844,70 @@ }, "time": "2021-08-31T14:56:54+00:00" }, + { + "name": "laravel/sanctum", + "version": "v2.13.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "b4c07d0014b78430a3c827064217f811f0708eaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/b4c07d0014b78430a3c827064217f811f0708eaa", + "reference": "b4c07d0014b78430a3c827064217f811f0708eaa", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/contracts": "^6.9|^7.0|^8.0", + "illuminate/database": "^6.9|^7.0|^8.0", + "illuminate/support": "^6.9|^7.0|^8.0", + "php": "^7.2|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.0|^5.0|^6.0", + "phpunit/phpunit": "^8.0|^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2021-12-14T17:49:47+00:00" + }, { "name": "laravel/scout", "version": "v9.2.8", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 000000000..e7d6cf174 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,65 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + env('APP_URL') ? ','.parse_url(env('APP_URL'), PHP_URL_HOST) : '', + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. If this value is null, personal access tokens do + | not expire. This won't tweak the lifetime of first-party sessions. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, + 'encrypt_cookies' => App\Http\Middleware\EncryptCookies::class, + ], + +]; diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php new file mode 100644 index 000000000..2bcbffaed --- /dev/null +++ b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php @@ -0,0 +1,21 @@ +bigIncrements('id'); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } +} diff --git a/database/schema/mysql-schema.dump b/database/schema/mysql-schema.dump index 5a8e23ec6..52d6d2cb4 100644 --- a/database/schema/mysql-schema.dump +++ b/database/schema/mysql-schema.dump @@ -130,6 +130,24 @@ CREATE TABLE `password_resets` ( KEY `password_resets_email_index` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `personal_access_tokens`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `personal_access_tokens` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `tokenable_type` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `tokenable_id` bigint unsigned NOT NULL, + `name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `abilities` text COLLATE utf8mb4_unicode_ci, + `last_used_at` timestamp NULL DEFAULT NULL, + `created_at` timestamp NULL DEFAULT NULL, + `updated_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `personal_access_tokens_token_unique` (`token`), + KEY `personal_access_tokens_tokenable_type_tokenable_id_index` (`tokenable_type`,`tokenable_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; DROP TABLE IF EXISTS `replies`; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; @@ -323,3 +341,4 @@ INSERT INTO `migrations` VALUES (63,'2021_09_12_220452_add_resolved_by_to_thread INSERT INTO `migrations` VALUES (64,'2021_10_31_143501_add_declined_at_column_to_articles_table',3); INSERT INTO `migrations` VALUES (65,'2021_11_15_213258_add_updated_by_to_threads_and_replies_table',4); INSERT INTO `migrations` VALUES (66,'2021_11_22_093555_migrate_thread_feed_to_timestamp',4); +INSERT INTO `migrations` VALUES (67,'2019_12_14_000001_create_personal_access_tokens_table',5); diff --git a/resources/views/users/settings/api_tokens.blade.php b/resources/views/users/settings/api_tokens.blade.php new file mode 100644 index 000000000..f6a676051 --- /dev/null +++ b/resources/views/users/settings/api_tokens.blade.php @@ -0,0 +1,50 @@ +@title('API Tokens') + +
+
+
+
+

+ API Tokens +

+

+ Create API tokens to access your account over our REST API. +

+
+ +
    + @foreach(Auth::user()->tokens as $token) +
  • + {{ $token->name }} + + {{ $token->token }} + + + + Delete Token + + +
  • + @endforeach +
+ +
+
+ Token name + + +
+
+
+ + +
+ + Generate New Token + +
+
+
+
diff --git a/resources/views/users/settings/password.blade.php b/resources/views/users/settings/password.blade.php index 84db0da80..adc2aae74 100644 --- a/resources/views/users/settings/password.blade.php +++ b/resources/views/users/settings/password.blade.php @@ -23,13 +23,13 @@ @endif
- New Password + New Password
- Confirm New Password + Confirm New Password
diff --git a/resources/views/users/settings/settings.blade.php b/resources/views/users/settings/settings.blade.php index 09d115946..c141bc9f7 100644 --- a/resources/views/users/settings/settings.blade.php +++ b/resources/views/users/settings/settings.blade.php @@ -17,6 +17,7 @@
@include('users.settings.profile') @include('users.settings.password') + @include('users.settings.api_tokens') @include('users.settings.remove')
diff --git a/routes/api.php b/routes/api.php index acfe57928..856681dd0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,10 @@ get('/user', function (Request $request) { -// return $request->user(); -// }); +// Articles +use App\Http\Controllers\Articles\ArticlesController; + +Route::prefix('articles')->middleware('throttle:api')->group(function () { + Route::post('/', [ArticlesController::class, 'store'])->name('api.articles.store'); + Route::put('{article}', [ArticlesController::class, 'update'])->name('api.articles.update'); + Route::delete('{article}', [ArticlesController::class, 'delete'])->name('api.articles.delete'); +}); diff --git a/routes/web.php b/routes/web.php index 5371e9c46..9dabef46c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,7 @@ use App\Http\Controllers\ProfileController; use App\Http\Controllers\ReplyAbleController; use App\Http\Controllers\ReplyController; +use App\Http\Controllers\Settings\ApiTokenController; use App\Http\Controllers\Settings\PasswordController; use App\Http\Controllers\Settings\ProfileController as ProfileSettingsController; use App\Http\Controllers\SocialImageController; @@ -73,6 +74,8 @@ Route::put('settings', [ProfileSettingsController::class, 'update'])->name('settings.profile.update'); Route::delete('settings', [ProfileSettingsController::class, 'destroy'])->name('settings.profile.delete'); Route::put('settings/password', [PasswordController::class, 'update'])->name('settings.password.update'); +Route::post('settings/api-tokens', [ApiTokenController::class, 'store'])->name('settings.api-tokens.store'); +Route::delete('settings/api-tokens', [ApiTokenController::class, 'destroy'])->name('settings.api-tokens.delete'); // Forum Route::prefix('forum')->group(function () { diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index d689380a6..09086c345 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -1,6 +1,7 @@ fresh()->twitter())->toBeEmpty(); }); +test('users can generate API tokens', function () { + $user = $this->createUser(); + + $this->loginAs($user); + + $this->visit('/settings') + ->submitForm('Generate New Token', [ + 'name' => 'My API Token', + ]) + ->seePageIs('/settings'); + + expect($user->refresh()->tokens)->toHaveCount(1); +}); + +test('users can delete API tokens', function () { + $user = $this->createUser(); + $token = $user->createToken('My API Token'); + + $this->loginAs($user); + + $this->visit('/settings') + ->submitForm('Delete Token', [ + 'id' => $token->accessToken->getKey(), + ]) + ->seePageIs('/settings'); + + expect($user->refresh()->tokens)->toBeEmpty(); +}); + +test('a user cannot delete another user\'s API token', function () { + $joe = UserFactory::new()->create(); + $token = $joe->createToken('Joe\'s API Token'); + + $adam = $this->createUser(); + $adam->createToken('Adam\'s API Token'); + $this->loginAs($adam); + + $this->visit('/settings') + ->submitForm('Delete Token', [ + 'id' => $token->accessToken->getKey(), + ]) + ->seePageIs('/settings'); + + expect($joe->refresh()->tokens)->toHaveCount(1); +}); + // Helpers function assertPasswordWasHashedAndSaved(): void { diff --git a/tests/Integration/Api/ArticleTest.php b/tests/Integration/Api/ArticleTest.php new file mode 100644 index 000000000..fb3bab367 --- /dev/null +++ b/tests/Integration/Api/ArticleTest.php @@ -0,0 +1,139 @@ +create(); + $user = $this->createUser(); + + Sanctum::actingAs($user); + + $this->postJson(route('api.articles.store'), array_merge([ + 'title' => 'Integrating with an API', + 'body' => '# Hello World', + 'tags' => [$tag->getKey()], + 'original_url' => 'https://laravel.com/docs/master/sanctum', + 'submitted' => false, + ], $body))->assertJson(['data' => array_merge([ + 'url' => route('articles.show', Article::query()->first()->slug()), + 'title' => 'Integrating with an API', + 'body' => '# Hello World', + 'original_url' => 'https://laravel.com/docs/master/sanctum', + 'author' => [ + 'email' => $user->emailAddress(), + 'name' => $user->name(), + 'bio' => $user->bio(), + 'twitter_handle' => $user->twitter(), + 'github_username' => $user->githubUsername(), + ], + 'tags' => [[ + 'id' => $tag->getKey(), + 'name' => $tag->name(), + 'slug' => $tag->slug(), + ]], + 'is_submitted' => false, + ], $response)]); + + expect(Article::query()->count())->toBe(1); +})->with('article API responses'); + +it('can update an article over the API', function (array $body, array $response) { + $tag = TagFactory::new()->create(); + $user = $this->createUser(); + + Sanctum::actingAs($user); + + $article = ArticleFactory::new()->for($user, 'authorRelation')->create(); + + $this->putJson(route('api.articles.update', $article->slug()), array_merge([ + 'title' => 'Integrating with an API', + 'body' => '# Hello World', + 'tags' => [$tag->getKey()], + 'original_url' => 'https://laravel.com/docs/master/sanctum', + 'submitted' => false, + ], $body))->assertJson(['data' => array_merge([ + 'url' => route('articles.show', Article::query()->first()->slug()), + 'title' => 'Integrating with an API', + 'body' => '# Hello World', + 'original_url' => 'https://laravel.com/docs/master/sanctum', + 'author' => [ + 'email' => $user->emailAddress(), + 'name' => $user->name(), + 'bio' => $user->bio(), + 'twitter_handle' => $user->twitter(), + 'github_username' => $user->githubUsername(), + ], + 'tags' => [[ + 'id' => $tag->getKey(), + 'name' => $tag->name(), + 'slug' => $tag->slug(), + ]], + 'is_submitted' => false, + ], $response)]); + + expect(Article::query()->count())->toBe(1); +})->with('article API responses'); + +it('can delete an article over the API', function () { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $article = ArticleFactory::new()->for($user, 'authorRelation')->create(); + + $this->deleteJson(route('api.articles.delete', $article->slug())) + ->assertNoContent(); + + expect(Article::query()->count())->toBe(0); +}); + +it('does not allow a guest to create', function () { + $this->postJson(route('api.articles.store')) + ->assertUnauthorized(); +}); + +it('does not allow a guest to update', function () { + $this->putJson(route('api.articles.update', ArticleFactory::new()->create()->slug())) + ->assertUnauthorized(); +}); + +it('does not allow a user to update another user\'s article', function () { + $user = $this->createUser(); + $article = ArticleFactory::new()->create(); + + Sanctum::actingAs($user); + + $this->putJson(route('api.articles.update', $article->slug()), [ + 'title' => 'Integrating with an API', + 'body' => '# Hello World', + 'submitted' => false, + ])->assertForbidden(); +}); + +it('does not allow a guest to delete', function () { + $this->deleteJson(route('api.articles.delete', ArticleFactory::new()->create()->slug())) + ->assertUnauthorized(); +}); + +it('does not allow a user to delete another user\'s article', function () { + $user = $this->createUser(); + $article = ArticleFactory::new()->create(); + + Sanctum::actingAs($user); + + $this->deleteJson(route('api.articles.delete', $article->slug())) + ->assertForbidden(); +}); + +dataset('article API responses', [ + 'default' => [[], []], + 'submitted for publishing' => [['submitted' => true], ['is_submitted' => true]], + 'no tags' => [['tags' => []], ['tags' => []]], +]); diff --git a/tests/Integration/Jobs/CreateApiTokenTest.php b/tests/Integration/Jobs/CreateApiTokenTest.php new file mode 100644 index 000000000..b1a965157 --- /dev/null +++ b/tests/Integration/Jobs/CreateApiTokenTest.php @@ -0,0 +1,18 @@ +createUser(); + + $this->dispatch(new CreateApiToken($user, 'Foo Bar')); + + expect($user->refresh()->tokens) + ->toHaveCount(1) + ->first()->name->toBe('Foo Bar'); +}); diff --git a/tests/Integration/Jobs/DeleteApiTokenTest.php b/tests/Integration/Jobs/DeleteApiTokenTest.php new file mode 100644 index 000000000..c35965cc6 --- /dev/null +++ b/tests/Integration/Jobs/DeleteApiTokenTest.php @@ -0,0 +1,32 @@ +createUser(); + + $user->createToken('foo'); + $barToken = $user->createToken('bar'); + $user->createToken('baz'); + + $this->dispatch(new DeleteApiToken($user, $barToken->accessToken->getKey())); + + expect($user->refresh()->tokens)->toHaveCount(2); + expect($barToken->accessToken->fresh())->toBeNull(); +}); + +it('will not delete anything if the given API token does not belong to the user', function () { + $user = $this->createUser(); + $token = $user->createToken('foo'); + + $this->dispatch(new DeleteApiToken(UserFactory::new()->create(), $token->accessToken->getKey())); + + expect($user->refresh()->tokens)->toHaveCount(1); + expect($token->accessToken->fresh())->not->toBeNull(); +});