From a9d9c1384613efa7609b210fdbb567c11d525e32 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Mon, 10 Feb 2025 20:44:16 -0500 Subject: [PATCH] Added API endpoints for user API tokens --- .env.ci | 41 ++-- .env.example | 22 ++- .github/workflows/npm-publish-api.yml | 3 +- .github/workflows/npm-publish-ui.yml | 3 +- .github/workflows/playwright.yml | 24 +-- .../Controllers/Api/V1/ApiTokenController.php | 84 ++++++++ .../V1/ApiToken/ApiTokenStoreRequest.php | 32 +++ .../V1/ApiToken/ApiTokenCollection.php | 17 ++ .../V1/ApiToken/ApiTokenResource.php | 32 +++ .../ApiTokenWithAccessTokenResource.php | 36 ++++ app/Models/Passport/AuthCode.php | 9 + app/Models/Passport/Client.php | 26 +++ app/Models/Passport/PersonalAccessClient.php | 9 + app/Models/Passport/RefreshToken.php | 9 + app/Models/Passport/Token.php | 27 +++ app/Models/User.php | 14 +- app/Providers/AuthServiceProvider.php | 15 ++ config/passport.php | 2 +- database/factories/Passport/ClientFactory.php | 55 ++++++ database/factories/Passport/TokenFactory.php | 54 +++++ database/seeders/DatabaseSeeder.php | 34 +++- phpunit.xml | 2 + routes/api.php | 9 + tests/Unit/Database/SeederTest.php | 9 + .../Endpoint/Api/V1/ApiTokenEndpointTest.php | 186 ++++++++++++++++++ 25 files changed, 707 insertions(+), 47 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/ApiTokenController.php create mode 100644 app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php create mode 100644 app/Http/Resources/V1/ApiToken/ApiTokenCollection.php create mode 100644 app/Http/Resources/V1/ApiToken/ApiTokenResource.php create mode 100644 app/Http/Resources/V1/ApiToken/ApiTokenWithAccessTokenResource.php create mode 100644 app/Models/Passport/AuthCode.php create mode 100644 app/Models/Passport/Client.php create mode 100644 app/Models/Passport/PersonalAccessClient.php create mode 100644 app/Models/Passport/RefreshToken.php create mode 100644 app/Models/Passport/Token.php create mode 100644 database/factories/Passport/ClientFactory.php create mode 100644 database/factories/Passport/TokenFactory.php create mode 100644 tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php diff --git a/.env.ci b/.env.ci index 262a31f3..40ce2768 100644 --- a/.env.ci +++ b/.env.ci @@ -1,3 +1,4 @@ +# Application APP_NAME=solidtime APP_ENV=local APP_KEY= @@ -19,35 +20,39 @@ DB_TEST_DATABASE=laravel DB_TEST_USERNAME=root DB_TEST_PASSWORD=root -BROADCAST_DRIVER=log +# Broadcasting +BROADCAST_DRIVER=null + +# Cache CACHE_DRIVER=file + +# Queue QUEUE_CONNECTION=sync + +# Session SESSION_DRIVER=database SESSION_LIFETIME=120 # Mail MAIL_MAILER=log -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" +MAIL_FROM_ADDRESS="no-reply@solidtime.test" +MAIL_FROM_NAME="solidtime" +MAIL_REPLY_TO_ADDRESS="hello@solidtime.test" +MAIL_REPLY_TO_NAME="solidtime" # Filesystems FILESYSTEM_DISK=local PUBLIC_FILESYSTEM_DISK=public +# Passport +PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92" +PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf" + +# Auditing +AUDITING_ENABLED=true + +# Telescope +TELESCOPE_ENABLED=false + # Services GOTENBERG_URL=http://0.0.0.0:3000 - -PUSHER_APP_ID= -PUSHER_APP_KEY= -PUSHER_APP_SECRET= -PUSHER_HOST= -PUSHER_PORT=443 -PUSHER_SCHEME=https -PUSHER_APP_CLUSTER=mt1 - -VITE_APP_NAME="${APP_NAME}" -VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" -VITE_PUSHER_HOST="${PUSHER_HOST}" -VITE_PUSHER_PORT="${PUSHER_PORT}" -VITE_PUSHER_SCHEME="${PUSHER_SCHEME}" -VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" diff --git a/.env.example b/.env.example index 24ed1310..0a4ea9f9 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ APP_ENV=local APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= APP_DEBUG=true APP_URL=https://solidtime.test -AUDITING_ENABLED=true +APP_FORCE_HTTPS=false APP_ENABLE_REGISTRATION=true SUPER_ADMINS=admin@example.com PAGINATION_PER_PAGE_DEFAULT=500 @@ -49,7 +49,9 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="no-reply@solidtime.test" -MAIL_FROM_NAME="${APP_NAME}" +MAIL_FROM_NAME="solidtime" +MAIL_REPLY_TO_ADDRESS="hello@solidtime.test" +MAIL_REPLY_TO_NAME="solidtime" # Filesystems FILESYSTEM_DISK=s3 @@ -62,14 +64,24 @@ S3_URL=http://storage.solidtime.test/local S3_ENDPOINT=http://storage.solidtime.test S3_USE_PATH_STYLE_ENDPOINT=true +# Passport +PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92" +PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf" + +# Auditing +AUDITING_ENABLED=true + +# Telescope +TELESCOPE_ENABLED=false + # Services GOTENBERG_URL=http://gotenberg:3000 -VITE_HOST_NAME=vite.solidtime.test -VITE_APP_NAME="${APP_NAME}" - # Local setup NGINX_HOST_NAME=solidtime.test NETWORK_NAME=reverse-proxy-docker-traefik_routing FORWARD_DB_PORT=5432 FORWARD_WEB_PORT=8083 +VITE_HOST_NAME=vite.solidtime.test +VITE_APP_NAME="${APP_NAME}" +#SAIL_XDEBUG_MODE=develop,debug,coverage diff --git a/.github/workflows/npm-publish-api.yml b/.github/workflows/npm-publish-api.yml index 3d77aa76..128228c3 100644 --- a/.github/workflows/npm-publish-api.yml +++ b/.github/workflows/npm-publish-api.yml @@ -8,7 +8,8 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4 + - name: "Checkout code" + uses: actions/checkout@v4 # Setup .npmrc file to publish to npm - name: Install root project dependencies run: npm ci diff --git a/.github/workflows/npm-publish-ui.yml b/.github/workflows/npm-publish-ui.yml index df16e2c9..2c5800db 100644 --- a/.github/workflows/npm-publish-ui.yml +++ b/.github/workflows/npm-publish-ui.yml @@ -8,7 +8,8 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@v4 + - name: "Checkout code" + uses: actions/checkout@v4 # Setup .npmrc file to publish to npm - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 630eb1e8..05d2c256 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -27,45 +27,47 @@ jobs: - name: "Checkout code" uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: "Setup node" + uses: actions/setup-node@v4 with: node-version: '20.x' - - name: Setup PHP + - name: "Setup PHP" uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv coverage: none - - name: Run composer install + - name: "Run composer install" run: composer install -n --prefer-dist - - name: Prepare Laravel Application + - name: "Prepare Laravel Application" run: | cp .env.ci .env php artisan key:generate - php artisan migrate --seed php artisan passport:keys + php artisan migrate --seed - - name: Install dependencies + - name: "Install dependencies" run: npm ci - - name: Build Frontend + - name: "Build Frontend" run: npm run build - - name: Run Laravel Server + - name: "Run Laravel Server" run: php artisan serve > /dev/null 2>&1 & - - name: Install Playwright Browsers + - name: "Install Playwright Browsers" run: npx playwright install --with-deps - - name: Run Playwright tests + - name: "Run Playwright tests" run: npx playwright test env: PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000' - - uses: actions/upload-artifact@v4 + - name: "Upload test results" + uses: actions/upload-artifact@v4 if: always() with: name: test-results diff --git a/app/Http/Controllers/Api/V1/ApiTokenController.php b/app/Http/Controllers/Api/V1/ApiTokenController.php new file mode 100644 index 00000000..140f4891 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ApiTokenController.php @@ -0,0 +1,84 @@ +user(); + + $tokens = $user->tokens()->get(); + + return new ApiTokenCollection($tokens); + } + + /** + * Create a new api token for the currently authenticated user + * + * The response will contain the access token that can be used to send authenticated API requests. + * Please note that the access token is only shown in this response and cannot be retrieved later. + * + * @throws AuthorizationException + */ + public function store(ApiTokenStoreRequest $request): ApiTokenWithAccessTokenResource + { + $user = $this->user(); + + $token = $user->createToken($request->getName(), ['*']); + /** @var Token $tokenModel */ + $tokenModel = $token->token; + + return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken); + } + + /** + * Revoke an api token + * + * @throws AuthorizationException + */ + public function revoke(string $apiTokenId): JsonResponse + { + $user = $this->user(); + + $apiToken = $user->tokens()->where('id', $apiTokenId)->firstOrFail(); + + $apiToken->revoke(); + + return response()->json(null, 204); + } + + /** + * Delete an api token + * + * @throws AuthorizationException + */ + public function destroy(string $apiTokenId): JsonResponse + { + $user = $this->user(); + + $apiToken = $user->tokens()->where('id', $apiTokenId)->firstOrFail(); + + $apiToken->delete(); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php b/app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php new file mode 100644 index 00000000..96c1bc3e --- /dev/null +++ b/app/Http/Requests/V1/ApiToken/ApiTokenStoreRequest.php @@ -0,0 +1,32 @@ +> + */ + public function rules(): array + { + return [ + 'name' => [ + 'required', + 'string', + 'min:1', + 'max:255', + ], + ]; + } + + public function getName(): string + { + return $this->input('name'); + } +} diff --git a/app/Http/Resources/V1/ApiToken/ApiTokenCollection.php b/app/Http/Resources/V1/ApiToken/ApiTokenCollection.php new file mode 100644 index 00000000..edf6a7e2 --- /dev/null +++ b/app/Http/Resources/V1/ApiToken/ApiTokenCollection.php @@ -0,0 +1,17 @@ +> + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->resource->id, + 'name' => $this->resource->name, + 'revoked' => $this->resource->revoked, + 'scopes' => $this->resource->scopes, + 'created_at' => $this->formatDateTime($this->resource->created_at), + 'expires_at' => $this->formatDateTime($this->resource->expires_at), + ]; + } +} diff --git a/app/Http/Resources/V1/ApiToken/ApiTokenWithAccessTokenResource.php b/app/Http/Resources/V1/ApiToken/ApiTokenWithAccessTokenResource.php new file mode 100644 index 00000000..b4b2ab39 --- /dev/null +++ b/app/Http/Resources/V1/ApiToken/ApiTokenWithAccessTokenResource.php @@ -0,0 +1,36 @@ +accessToken = $accessToken; + parent::__construct($resource); + } + + /** + * Transform the resource into an array. + * + * @return array> + */ + public function toArray(Request $request): array + { + $parent = parent::toArray($request); + + return $parent + [ + 'access_token' => $this->accessToken, + ]; + } +} diff --git a/app/Models/Passport/AuthCode.php b/app/Models/Passport/AuthCode.php new file mode 100644 index 00000000..f0187dc3 --- /dev/null +++ b/app/Models/Passport/AuthCode.php @@ -0,0 +1,9 @@ + */ + use HasFactory; +} diff --git a/app/Models/Passport/PersonalAccessClient.php b/app/Models/Passport/PersonalAccessClient.php new file mode 100644 index 00000000..c1cbb7de --- /dev/null +++ b/app/Models/Passport/PersonalAccessClient.php @@ -0,0 +1,9 @@ + $scopes + * @property bool $revoked + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property Carbon|null $expires_at + */ +class Token extends PassportToken +{ + /** @use HasFactory */ + use HasFactory; +} diff --git a/app/Models/User.php b/app/Models/User.php index 54906861..2a40cd5d 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Enums\Weekday; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; +use App\Models\Passport\Token; use Database\Factories\UserFactory; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; @@ -27,7 +28,6 @@ use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasTeams; use Laravel\Passport\AuthCode; use Laravel\Passport\HasApiTokens; -use Laravel\Passport\Token; use OwenIt\Auditing\Contracts\Auditable as AuditableContract; /** @@ -44,6 +44,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property-read Organization|null $currentOrganization * @property-read Organization|null $currentTeam * @property-read string $profile_photo_url + * @property-read Collection $tokens * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property string|null $current_team_id @@ -196,6 +197,17 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M return $this->hasMany(AuthCode::class); } + /** + * Get the access tokens for the user. + * + * @return HasMany + */ + public function tokens(): HasMany + { + return $this->hasMany(Token::class, 'user_id') + ->orderBy('created_at', 'desc'); + } + /** * @param Builder $builder */ diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index e7a414c9..15ea2d43 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -5,6 +5,11 @@ declare(strict_types=1); namespace App\Providers; use App\Models\Organization; +use App\Models\Passport\AuthCode; +use App\Models\Passport\Client; +use App\Models\Passport\PersonalAccessClient; +use App\Models\Passport\RefreshToken; +use App\Models\Passport\Token; use App\Policies\OrganizationPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Laravel\Jetstream\Jetstream; @@ -42,6 +47,16 @@ class AuthServiceProvider extends ServiceProvider // 'delete', ]); + Passport::useTokenModel(Token::class); + Passport::useRefreshTokenModel(RefreshToken::class); + Passport::useAuthCodeModel(AuthCode::class); + Passport::useClientModel(Client::class); + Passport::usePersonalAccessClientModel(PersonalAccessClient::class); + + // Passport::tokensExpireIn(now()->addDays(15)); + // Passport::refreshTokensExpireIn(now()->addDays(30)); + Passport::personalAccessTokensExpireIn(now()->addMonths(12)); + // same as passport default above Jetstream::defaultApiTokenPermissions(['read']); diff --git a/config/passport.php b/config/passport.php index 99f8aa8f..7c129d27 100644 --- a/config/passport.php +++ b/config/passport.php @@ -15,7 +15,7 @@ return [ | */ - 'guard' => 'web', + 'guard' => 'api', /* |-------------------------------------------------------------------------- diff --git a/database/factories/Passport/ClientFactory.php b/database/factories/Passport/ClientFactory.php new file mode 100644 index 00000000..b38aedd2 --- /dev/null +++ b/database/factories/Passport/ClientFactory.php @@ -0,0 +1,55 @@ + + */ +class ClientFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'id' => $this->faker->uuid, + 'user_id' => null, + 'name' => $this->faker->company(), + 'secret' => $this->faker->regexify('[A-Za-z]{40}'), + 'provider' => 'users', + 'redirect' => $this->faker->url(), + 'personal_access_client' => false, + 'password_client' => false, + 'revoked' => false, + 'created_at' => $this->faker->dateTime(), + 'updated_at' => $this->faker->dateTime(), + ]; + } + + public function personalAccessClient(): self + { + return $this->state(function (array $attributes) { + return [ + 'personal_access_client' => true, + ]; + }); + } + + public function forUser(User $user): self + { + return $this->state(function (array $attributes) use ($user): array { + return [ + 'user_id' => $user->getKey(), + ]; + }); + } +} diff --git a/database/factories/Passport/TokenFactory.php b/database/factories/Passport/TokenFactory.php new file mode 100644 index 00000000..16b86422 --- /dev/null +++ b/database/factories/Passport/TokenFactory.php @@ -0,0 +1,54 @@ + + */ +class TokenFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'id' => $this->faker->uuid, + 'user_id' => null, + 'client_id' => $this->faker->uuid, + 'name' => null, + 'scopes' => [], + 'revoked' => false, + 'created_at' => $this->faker->dateTime, + 'updated_at' => $this->faker->dateTime, + 'expires_at' => $this->faker->dateTime, + ]; + } + + public function forUser(User $user): self + { + return $this->state(function (array $attributes) use ($user): array { + return [ + 'user_id' => $user->getKey(), + ]; + }); + } + + public function forClient(Client $client): self + { + return $this->state(function (array $attributes) use ($client): array { + return [ + 'client_id' => $client->getKey(), + ]; + }); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 78c6eb36..a4a9fdda 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -34,6 +34,29 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->deleteAll(); + + app(ClientRepository::class)->create( + null, + 'desktop', + 'solidtime://oauth/callback', + null, + false, + false, + false + ); + + $personalAccessClient = new PassportClient; + $personalAccessClient->id = config('passport.personal_access_client.id'); + $personalAccessClient->secret = config('passport.personal_access_client.secret'); + $personalAccessClient->name = 'API'; + $personalAccessClient->redirect = 'http://localhost'; + $personalAccessClient->user_id = null; + $personalAccessClient->revoked = false; + $personalAccessClient->provider = null; + $personalAccessClient->personal_access_client = true; + $personalAccessClient->password_client = false; + $personalAccessClient->save(); + $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ 'name' => 'Mister Overemployed', 'email' => 'overemployed@acme.test', @@ -55,6 +78,8 @@ class DatabaseSeeder extends Seeder 'name' => 'Acme Manager', 'email' => 'test@example.com', ]); + $userAcmeManager->createToken('Testing Token 1')->accessToken; + $userAcmeManager->createToken('Testing Token 2')->accessToken; $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Admin', 'email' => 'admin@acme.test', @@ -159,15 +184,6 @@ class DatabaseSeeder extends Seeder 'email' => 'admin@example.com', ]); - app(ClientRepository::class)->create( - null, - 'desktop', - 'solidtime://oauth/callback', - null, - false, - false, - false - ); } private function deleteAll(): void diff --git a/phpunit.xml b/phpunit.xml index db535f91..5e6edca4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -41,5 +41,7 @@ + + diff --git a/routes/api.php b/routes/api.php index d657576e..12fb239e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Http\Controllers\Api\V1\ApiTokenController; use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ExportController; use App\Http\Controllers\Api\V1\ImportController; @@ -57,6 +58,14 @@ Route::prefix('v1')->name('v1.')->group(static function (): void { Route::get('/users/me', [UserController::class, 'me'])->name('me'); }); + // Api token routes + Route::name('api-tokens.')->group(static function (): void { + Route::get('/users/me/api-tokens', [ApiTokenController::class, 'index'])->name('index'); + Route::post('/users/me/api-tokens', [ApiTokenController::class, 'store'])->name('store'); + Route::post('/users/me/api-tokens/{apiTokenId}/revoke', [ApiTokenController::class, 'revoke'])->name('revoke'); + Route::delete('/users/me/api-tokens/{apiTokenId}', [ApiTokenController::class, 'destroy'])->name('destroy'); + }); + // User Member routes Route::name('users.memberships.')->group(static function (): void { Route::get('/users/me/memberships', [UserMembershipController::class, 'myMemberships'])->name('my-memberships'); diff --git a/tests/Unit/Database/SeederTest.php b/tests/Unit/Database/SeederTest.php index de72a075..920a93af 100644 --- a/tests/Unit/Database/SeederTest.php +++ b/tests/Unit/Database/SeederTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Database; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Tests\TestCase; class SeederTest extends TestCase @@ -13,6 +14,7 @@ class SeederTest extends TestCase public function test_running_the_seeder_multiple_times_runs_successfully(): void { + $this->setupForSeeder(); $this->artisan('db:seed') ->assertSuccessful(); $this->artisan('db:seed') @@ -21,9 +23,16 @@ class SeederTest extends TestCase public function test_fresh_migration_with_seeder_and_rollback_runs_successfully(): void { + $this->setupForSeeder(); $this->artisan('db:seed') ->assertSuccessful(); $this->artisan('migrate:rollback') ->assertSuccessful(); } + + private function setupForSeeder(): void + { + Config::set('passport.personal_access_client.id', '9e27f54d-5dfb-4dde-99d7-834518236c92'); + Config::set('passport.personal_access_client.secret', 'EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf'); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php new file mode 100644 index 00000000..3ad238dc --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/ApiTokenEndpointTest.php @@ -0,0 +1,186 @@ +createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + $otherData = $this->createUserWithPermission([]); + $otherToken = Token::factory()->forUser($otherData->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.api-tokens.index')); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertExactJson([ + 'data' => [ + [ + 'id' => $token->id, + 'name' => $token->name, + 'scopes' => $token->scopes, + 'revoked' => $token->revoked, + 'created_at' => $token->created_at->toIso8601ZuluString(), + 'expires_at' => $token->expires_at->toIso8601ZuluString(), + ], + ], + ]); + } + + public function test_store_endpoint_creates_new_api_token(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + Config::set('passport.personal_access_client.id', $client->id); + Config::set('passport.personal_access_client.secret', $client->secret); + Passport::actingAs($data->user); + + // Act + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.api-tokens.store'), [ + 'name' => 'Test Token', + ]); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJsonStructure([ + 'data' => [ + 'id', + 'name', + 'scopes', + 'revoked', + 'created_at', + 'expires_at', + 'access_token', + ], + ]); + } + + public function test_revoke_endpoint_revokes_api_token(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id)); + + // Assert + $this->assertResponseCode($response, 204); + $this->assertDatabaseHas(Token::class, [ + 'id' => $token->id, + 'revoked' => true, + ]); + } + + public function test_revoke_fails_if_token_with_id_does_not_exist(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.api-tokens.revoke', 'not-valid')); + + // Assert + $this->assertResponseCode($response, 404); + } + + public function test_revoke_fails_if_the_token_does_not_belong_to_the_user(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $otherData = $this->createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + $token = Token::factory()->forUser($otherData->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.api-tokens.revoke', $token->id)); + + // Assert + $this->assertResponseCode($response, 404); + $this->assertDatabaseHas(Token::class, [ + 'id' => $token->id, + 'revoked' => false, + ]); + } + + public function test_destroy_endpoint_deletes_api_token(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + $token = Token::factory()->forUser($data->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id)); + + // Assert + $this->assertResponseCode($response, 204); + $this->assertDatabaseMissing(Token::class, ['id' => $token->id]); + } + + public function test_destroy_fails_if_token_with_id_does_not_exist(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.api-tokens.destroy', 'not-valid')); + + // Assert + $this->assertResponseCode($response, 404); + } + + public function test_destroy_fails_if_the_token_does_not_belong_to_the_user(): void + { + // Arrange + $data = $this->createUserWithPermission([]); + $otherData = $this->createUserWithPermission([]); + $client = $this->createPersonalAccessClient(); + $token = Token::factory()->forUser($otherData->user)->forClient($client)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.api-tokens.destroy', $token->id)); + + // Assert + $this->assertResponseCode($response, 404); + $this->assertDatabaseHas(Token::class, [ + 'id' => $token->id, + ]); + } + + private function createPersonalAccessClient(): Client + { + $clientRepository = new ClientRepository; + /** @var Client $client */ + $client = $clientRepository->createPersonalAccessClient( + null, 'Test Personal Access Client', 'http://localhost' + ); + + return $client; + } +}