From eda0ffd1999fef77588faa9dc07217b7de751cef Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Thu, 21 May 2026 20:47:10 +0200 Subject: [PATCH] feat: deauthorize strava --- app/Http/Controllers/StravaController.php | 35 +++++++++- app/Services/StravaClient.php | 14 ++++ lang/en/api.php | 1 + lang/fr/api.php | 1 + tests/Feature/StravaConnectionTest.php | 83 +++++++++++++++++++++++ 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/StravaController.php b/app/Http/Controllers/StravaController.php index 2e7741c..6b586e8 100644 --- a/app/Http/Controllers/StravaController.php +++ b/app/Http/Controllers/StravaController.php @@ -8,6 +8,8 @@ use App\Http\Requests\StravaSyncRequest; use App\Models\StravaConnection; use App\Models\WorkoutSessions; use App\Services\StravaClient; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -86,20 +88,47 @@ class StravaController extends Controller public function status(Request $request): JsonResponse { $connection = $request->user()->stravaConnection; + $scopes = $connection?->scopes ?? []; return response()->json([ 'data' => [ 'connected' => $connection !== null, 'athleteId' => $connection?->athlete_id, - 'scopes' => $connection?->scopes ?? [], + 'canReadActivities' => $connection !== null && $this->canReadActivities($scopes), + 'requestedScopes' => $this->normalizeScopes(config('services.strava.scope', 'read,activity:read')), + 'scopes' => $scopes, 'expiresAt' => $connection?->expires_at, ], ]); } - public function disconnect(Request $request): Response + public function disconnect(Request $request, StravaClient $strava): Response|JsonResponse { - $request->user()->stravaConnection()->delete(); + $connection = $request->user()->stravaConnection; + + if (! $connection) { + return response()->noContent(); + } + + try { + $strava->deauthorize($connection); + } catch (RequestException $exception) { + if ($exception->response->status() !== 401) { + report($exception); + + return response()->json([ + 'message' => __('api.strava.disconnect_failed'), + ], 502); + } + } catch (ConnectionException $exception) { + report($exception); + + return response()->json([ + 'message' => __('api.strava.disconnect_failed'), + ], 502); + } + + $connection->delete(); return response()->noContent(); } diff --git a/app/Services/StravaClient.php b/app/Services/StravaClient.php index 57042b0..e3974e7 100644 --- a/app/Services/StravaClient.php +++ b/app/Services/StravaClient.php @@ -64,6 +64,20 @@ class StravaClient return $connection->refresh(); } + public function deauthorize(StravaConnection $connection): void + { + $accessToken = $this->accessToken($connection); + + Http::baseUrl('https://www.strava.com') + ->timeout(10) + ->connectTimeout(3) + ->retry([100, 500, 1000]) + ->post('/oauth/deauthorize?'.http_build_query([ + 'access_token' => $accessToken, + ], '', '&', PHP_QUERY_RFC3986)) + ->throw(); + } + public function activities(StravaConnection $connection, int $page, int $perPage): array { return Http::baseUrl('https://www.strava.com/api/v3') diff --git a/lang/en/api.php b/lang/en/api.php index 847ebba..0daccd4 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -34,6 +34,7 @@ return [ 'refused' => 'Strava connection refused.', 'missing_connection' => 'Connect Strava before syncing your workouts.', 'missing_activity_scope' => 'The Strava connection must allow the activity:read or activity:read_all scope.', + 'disconnect_failed' => 'Unable to revoke Strava access right now. Please try again in a moment.', ], 'validation' => [ 'image_required' => 'Add an image to analyze.', diff --git a/lang/fr/api.php b/lang/fr/api.php index 5e16808..8a5c710 100644 --- a/lang/fr/api.php +++ b/lang/fr/api.php @@ -34,6 +34,7 @@ return [ 'refused' => 'Connexion Strava refusée.', 'missing_connection' => 'Connecte Strava avant de synchroniser tes workouts.', 'missing_activity_scope' => 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.', + 'disconnect_failed' => "Impossible de retirer l'accès Strava pour le moment. Réessaie dans quelques instants.", ], 'validation' => [ 'image_required' => 'Ajoute une image à analyser.', diff --git a/tests/Feature/StravaConnectionTest.php b/tests/Feature/StravaConnectionTest.php index 9b9a377..a5d878d 100644 --- a/tests/Feature/StravaConnectionTest.php +++ b/tests/Feature/StravaConnectionTest.php @@ -96,6 +96,89 @@ it('stores strava tokens from an authorization code', function () { ]); }); +it('revokes strava access before deleting the local connection', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://www.strava.com/oauth/deauthorize*' => Http::response([ + 'access_token' => 'access-token', + ]), + ]); + + $user = User::factory()->create(); + StravaConnection::create([ + 'user_id' => $user->id, + 'athlete_id' => 123456, + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => now()->addHour(), + 'scopes' => ['read', 'activity:read'], + 'athlete' => ['id' => 123456], + ]); + + Sanctum::actingAs($user); + + $this->deleteJson('/api/strava/connection')->assertNoContent(); + + expect($user->fresh()->stravaConnection)->toBeNull(); + + Http::assertSent(fn ($request): bool => $request->url() === 'https://www.strava.com/oauth/deauthorize?access_token=access-token'); +}); + +it('deletes the local strava connection when strava already rejects the token', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://www.strava.com/oauth/deauthorize*' => Http::response([ + 'message' => 'Authorization Error', + ], 401), + ]); + + $user = User::factory()->create(); + StravaConnection::create([ + 'user_id' => $user->id, + 'athlete_id' => 123456, + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => now()->addHour(), + 'scopes' => ['read', 'activity:read'], + 'athlete' => ['id' => 123456], + ]); + + Sanctum::actingAs($user); + + $this->deleteJson('/api/strava/connection')->assertNoContent(); + + expect($user->fresh()->stravaConnection)->toBeNull(); +}); + +it('keeps the local strava connection when strava revocation fails temporarily', function () { + Http::preventStrayRequests(); + Http::fake([ + 'https://www.strava.com/oauth/deauthorize*' => Http::response([ + 'message' => 'temporary failure', + ], 503), + ]); + + $user = User::factory()->create(); + StravaConnection::create([ + 'user_id' => $user->id, + 'athlete_id' => 123456, + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => now()->addHour(), + 'scopes' => ['read', 'activity:read'], + 'athlete' => ['id' => 123456], + ]); + + Sanctum::actingAs($user); + + $this + ->deleteJson('/api/strava/connection') + ->assertStatus(502) + ->assertJsonPath('message', __('api.strava.disconnect_failed')); + + expect($user->fresh()->stravaConnection)->not->toBeNull(); +}); + it('syncs strava activities into workouts', function () { Http::preventStrayRequests(); Http::fake([