diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 86828cc..d67ab55 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -46,11 +46,11 @@ class DashboardController extends Controller $shippingConfig = $settings->for('parcel2go'); if ($isAdmin && filled($shippingConfig['client_id'] ?? null) && filled($shippingConfig['client_secret'] ?? null) && filled($shippingConfig['auth_url'] ?? null)) { try { + $parcel2Go->verifyCredentials(); if ($parcel2Go->hasScope('my-profile')) { $shipments = array_slice($parcel2Go->fetch('me/orders/detail')['Orders'] ?? [], 0, 4); $shippingStatus = 'Live account'; } else { - $parcel2Go->verifyCredentials(); $shippingStatus = 'Quotes available'; } } catch (Throwable $exception) { diff --git a/app/Http/Controllers/ShippingController.php b/app/Http/Controllers/ShippingController.php index cc22ca1..84a3b3e 100644 --- a/app/Http/Controllers/ShippingController.php +++ b/app/Http/Controllers/ShippingController.php @@ -15,6 +15,7 @@ class ShippingController extends Controller { abort_unless($request->user()->role === 'admin', 403); try { + $parcel2Go->verifyCredentials(); $account = []; $balance = []; $orders = []; @@ -31,8 +32,9 @@ class ShippingController extends Controller $profileNotice = 'Parcel2Go connected, but account history is unavailable. Check that this client has my-profile access.'; } } else { - $parcel2Go->verifyCredentials(); - $profileNotice = 'Parcel2Go connected for quotes and collections. Add my-profile access in Parcel2Go to show account history and labels.'; + $profileNotice = $parcel2Go->scopeWasReduced() + ? 'Parcel2Go rejected the selected API access. Basic shipping access works, but account history and labels need my-profile permission for this client.' + : 'Parcel2Go connected for quotes and collections. Add my-profile access in Parcel2Go to show account history and labels.'; } return view('shipping', compact('account', 'balance', 'orders', 'profileAvailable', 'profileNotice') + [ diff --git a/app/Services/Parcel2Go.php b/app/Services/Parcel2Go.php index 6acc594..a04814b 100644 --- a/app/Services/Parcel2Go.php +++ b/app/Services/Parcel2Go.php @@ -2,17 +2,29 @@ namespace App\Services; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; use RuntimeException; class Parcel2Go { + private ?string $token = null; + + private ?string $activeScope = null; + + private bool $reducedScope = false; + public function __construct(private IntegrationSettings $settings) {} public function hasScope(string $scope): bool { - return in_array($scope, explode(' ', (string) ($this->settings->for('parcel2go')['scope'] ?? 'public-api')), true); + return in_array($scope, explode(' ', $this->activeScope ?? (string) ($this->settings->for('parcel2go')['scope'] ?? 'public-api')), true); + } + + public function scopeWasReduced(): bool + { + return $this->reducedScope; } public function verifyCredentials(): void @@ -107,22 +119,41 @@ class Parcel2Go private function accessToken(): string { + if ($this->token !== null) { + return $this->token; + } + $settings = $this->settings->for('parcel2go'); if (! $settings['client_id'] || ! $settings['client_secret'] || ! $settings['auth_url']) { throw new RuntimeException('Parcel2Go credentials are not configured.'); } - $token = Http::asForm()->timeout(10)->post($settings['auth_url'], [ - 'grant_type' => 'client_credentials', - 'scope' => $settings['scope'] ?? 'public-api', - 'client_id' => $settings['client_id'], - 'client_secret' => $settings['client_secret'], - ])->throw()->json('access_token'); + $scope = (string) ($settings['scope'] ?? 'public-api'); + $response = $this->tokenResponse($settings, $scope); + if ($scope !== 'public-api' && $response->status() === 400 && $response->json('error') === 'invalid_scope') { + $scope = 'public-api'; + $response = $this->tokenResponse($settings, $scope); + $this->reducedScope = true; + } + $token = $response->throw()->json('access_token'); if (! $token) { throw new RuntimeException('Parcel2Go did not return an access token.'); } - return $token; + $this->activeScope = $scope; + + return $this->token = $token; + } + + /** @param array{auth_url: string, client_id: string, client_secret: string} $settings */ + private function tokenResponse(array $settings, string $scope): Response + { + return Http::asForm()->timeout(10)->post($settings['auth_url'], [ + 'grant_type' => 'client_credentials', + 'scope' => $scope, + 'client_id' => $settings['client_id'], + 'client_secret' => $settings['client_secret'], + ]); } } diff --git a/tests/Feature/DashboardAccessTest.php b/tests/Feature/DashboardAccessTest.php index 9bcb0cc..bf354e0 100644 --- a/tests/Feature/DashboardAccessTest.php +++ b/tests/Feature/DashboardAccessTest.php @@ -197,7 +197,7 @@ class DashboardAccessTest extends TestCase $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin)->get(route('shipping'))->assertOk()->assertSee('Lee Hickman')->assertSee('12.50'); - Http::assertSentCount(6); + Http::assertSentCount(4); Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/token' && $request['scope'] === 'public-api my-profile'); } diff --git a/tests/Feature/IntegrationsTest.php b/tests/Feature/IntegrationsTest.php index 20dd054..9e1c78b 100644 --- a/tests/Feature/IntegrationsTest.php +++ b/tests/Feature/IntegrationsTest.php @@ -103,6 +103,28 @@ class IntegrationsTest extends TestCase Http::assertSentCount(1); } + public function test_shipping_falls_back_to_basic_access_when_parcel2go_rejects_profile_scope(): void + { + Http::preventStrayRequests(); + Http::fake(['https://auth.example.test/token' => function ($request) { + return $request['scope'] === 'public-api' + ? Http::response(['access_token' => 'test-token']) + : Http::response(['error' => 'invalid_scope'], 400); + }]); + config()->set('services.parcel2go', [ + 'client_id' => 'id', 'client_secret' => 'secret', + 'auth_url' => 'https://auth.example.test/token', + 'base_url' => 'https://shipping.example.test/api', + 'scope' => 'public-api my-profile', + ]); + + $this->actingAs(User::factory()->create(['role' => 'admin']))->get(route('shipping')) + ->assertOk()->assertSee('Parcel2Go rejected the selected API access') + ->assertDontSee('Parcel2Go connected, but account history is unavailable') + ->assertDontSee('Parcel2Go is unavailable'); + Http::assertSentCount(2); + } + public function test_shipping_label_is_downloaded_for_an_order_in_account_history(): void { Http::preventStrayRequests();