Add Parcel2Go account consent with PKCE
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 26s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 26s
This commit is contained in:
@@ -7,13 +7,14 @@ use App\Models\EtsyConnection;
|
||||
use App\Models\IntegrationSetting;
|
||||
use App\Services\Etsy;
|
||||
use App\Services\IntegrationSettings;
|
||||
use App\Services\Parcel2Go;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EtsySettingsController extends Controller
|
||||
{
|
||||
public function index(Request $request, Etsy $etsy, IntegrationSettings $settings): View
|
||||
public function index(Request $request, Etsy $etsy, IntegrationSettings $settings, Parcel2Go $parcel2Go): View
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
|
||||
@@ -37,6 +38,9 @@ class EtsySettingsController extends Controller
|
||||
'base_url' => $parcel2go['base_url'] ?? '',
|
||||
'scope' => $parcel2go['scope'] ?? 'public-api',
|
||||
'collection_postcode' => $parcel2go['collection_postcode'] ?? '',
|
||||
'profile_client_configured' => $parcel2Go->profileClientConfigured(),
|
||||
'profile_connected' => $parcel2Go->profileConnected(),
|
||||
'callback_url' => route('parcel2go.callback'),
|
||||
],
|
||||
'bambuddy' => [
|
||||
'stored' => in_array('bambuddy', $storedIntegrations, true),
|
||||
|
||||
@@ -22,6 +22,7 @@ class IntegrationSettingsController extends Controller
|
||||
'base_url' => ['nullable', 'url:https', 'max:2048'],
|
||||
'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'],
|
||||
'collection_postcode' => ['nullable', 'string', 'max:12'],
|
||||
'profile_client_id' => ['nullable', 'string', 'max:255'],
|
||||
],
|
||||
default => [
|
||||
'url' => ['nullable', 'url:https', 'max:2048'],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Parcel2Go;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Throwable;
|
||||
|
||||
class Parcel2GoConnectionController extends Controller
|
||||
{
|
||||
public function connect(Request $request, Parcel2Go $parcel2Go): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
if (! $parcel2Go->profileClientConfigured()) {
|
||||
return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Save the Parcel2Go Authorization Code Client ID first.']);
|
||||
}
|
||||
|
||||
$state = bin2hex(random_bytes(24));
|
||||
$verifier = rtrim(strtr(base64_encode(random_bytes(48)), '+/', '-_'), '=');
|
||||
$request->session()->put('parcel2go_oauth', ['state' => $state, 'verifier' => $verifier, 'started' => time()]);
|
||||
|
||||
return redirect()->away($parcel2Go->authorizationUrl($state, $verifier));
|
||||
}
|
||||
|
||||
public function callback(Request $request, Parcel2Go $parcel2Go): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->role === 'admin', 403);
|
||||
$pending = $request->session()->pull('parcel2go_oauth');
|
||||
if (! is_array($pending) || time() - ($pending['started'] ?? 0) > 600
|
||||
|| ! is_string($request->query('state'))
|
||||
|| ! hash_equals($pending['state'] ?? '', $request->query('state'))
|
||||
|| ! is_string($request->query('code'))) {
|
||||
return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Parcel2Go consent expired or was not verified. Please try again.']);
|
||||
}
|
||||
|
||||
try {
|
||||
$parcel2Go->connectProfile($request->query('code'), $pending['verifier']);
|
||||
|
||||
return redirect()->route('settings.index')->with('status', 'Parcel2Go account connected. Shipment history is available in Shipping.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()->route('settings.index')->withErrors(['parcel2go' => 'Parcel2Go rejected the account connection. Check the Client ID, scopes and callback URL.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,11 +42,13 @@ class ShippingController extends Controller
|
||||
$orders = $parcel2Go->fetch('me/orders/detail');
|
||||
$historyAvailable = true;
|
||||
} catch (Throwable $exception) {
|
||||
$profileNotice = $exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true)
|
||||
? 'Parcel2Go denied shipment history for this client. Request my-profile access from Parcel2Go API support.'
|
||||
: 'Parcel2Go shipment history is temporarily unavailable.';
|
||||
$profileNotice = $parcel2Go->profileConnected()
|
||||
? 'Parcel2Go account access needs attention. Reconnect the account in Settings.'
|
||||
: ($exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true)
|
||||
? 'Parcel2Go denied shipment history for this client. Connect an Authorization Code client in Settings.'
|
||||
: 'Parcel2Go shipment history is temporarily unavailable.');
|
||||
}
|
||||
if ($parcel2Go->hasScope('my-profile')) {
|
||||
if ($parcel2Go->profileConnected() || $parcel2Go->hasScope('my-profile')) {
|
||||
try {
|
||||
$account = $parcel2Go->fetch('me/detail');
|
||||
$balance = $parcel2Go->fetch('me/prepay/balance');
|
||||
|
||||
+114
-2
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\IntegrationSetting;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
@@ -15,6 +17,8 @@ class Parcel2Go
|
||||
|
||||
private bool $reducedScope = false;
|
||||
|
||||
private ?string $profileToken = null;
|
||||
|
||||
public function __construct(private IntegrationSettings $settings) {}
|
||||
|
||||
public function hasScope(string $scope): bool
|
||||
@@ -32,9 +36,70 @@ class Parcel2Go
|
||||
$this->accessToken();
|
||||
}
|
||||
|
||||
public function profileConnected(): bool
|
||||
{
|
||||
$connection = IntegrationSetting::find('parcel2go_oauth')?->settings;
|
||||
|
||||
return filled($connection['access_token'] ?? null)
|
||||
&& ($connection['client_id'] ?? null) === ($this->settings->for('parcel2go')['profile_client_id'] ?? null);
|
||||
}
|
||||
|
||||
public function profileClientConfigured(): bool
|
||||
{
|
||||
return filled($this->settings->for('parcel2go')['profile_client_id'] ?? null);
|
||||
}
|
||||
|
||||
public function authorizationUrl(string $state, string $verifier): string
|
||||
{
|
||||
$settings = $this->settings->for('parcel2go');
|
||||
if (blank($settings['profile_client_id'] ?? null)) {
|
||||
throw new RuntimeException('Parcel2Go Authorization Code Client ID is not configured.');
|
||||
}
|
||||
|
||||
return $this->authorizationEndpoint().'?'.http_build_query([
|
||||
'client_id' => $settings['profile_client_id'],
|
||||
'redirect_uri' => route('parcel2go.callback'),
|
||||
'response_type' => 'code',
|
||||
'scope' => 'public-api my-profile offline_access',
|
||||
'state' => $state,
|
||||
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='),
|
||||
'code_challenge_method' => 'S256',
|
||||
], '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
public function connectProfile(string $code, string $verifier): void
|
||||
{
|
||||
$settings = $this->settings->for('parcel2go');
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post($settings['auth_url'], [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => $settings['profile_client_id'],
|
||||
'redirect_uri' => route('parcel2go.callback'),
|
||||
'code' => $code,
|
||||
'code_verifier' => $verifier,
|
||||
])->throw()->json();
|
||||
if (empty($tokens['access_token'])) {
|
||||
throw new RuntimeException('Parcel2Go did not return a profile access token.');
|
||||
}
|
||||
Http::withToken($tokens['access_token'])->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
->get($this->url('me/orders/detail'))->throw();
|
||||
|
||||
IntegrationSetting::updateOrCreate(['provider' => 'parcel2go_oauth'], ['settings' => [
|
||||
'client_id' => $settings['profile_client_id'],
|
||||
'access_token' => $tokens['access_token'],
|
||||
'refresh_token' => $tokens['refresh_token'] ?? null,
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(),
|
||||
]]);
|
||||
$this->profileToken = $tokens['access_token'];
|
||||
}
|
||||
|
||||
public function fetch(string $path): array
|
||||
{
|
||||
return Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
$token = str_starts_with(ltrim($path, '/'), 'me/') && $this->profileConnected()
|
||||
? $this->profileAccessToken()
|
||||
: $this->accessToken();
|
||||
|
||||
return Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(15)
|
||||
->get($this->url($path))->throw()->json() ?? [];
|
||||
}
|
||||
|
||||
@@ -98,7 +163,8 @@ class Parcel2Go
|
||||
|
||||
public function labelPdf(string $reference, string $hash): string
|
||||
{
|
||||
$data = Http::withToken($this->accessToken())->acceptJson()->connectTimeout(3)->timeout(20)
|
||||
$token = $this->profileConnected() ? $this->profileAccessToken() : $this->accessToken();
|
||||
$data = Http::withToken($token)->acceptJson()->connectTimeout(3)->timeout(20)
|
||||
->get($this->url('labels/'.$reference), [
|
||||
'referenceType' => 'OrderLineId', 'detailLevel' => 'Labels',
|
||||
'labelMedia' => 'A4', 'labelFormat' => 'PDF', 'hash' => $hash,
|
||||
@@ -117,6 +183,52 @@ class Parcel2Go
|
||||
return rtrim($this->settings->for('parcel2go')['base_url'], '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function authorizationEndpoint(): string
|
||||
{
|
||||
$tokenUrl = $this->settings->for('parcel2go')['auth_url'] ?? '';
|
||||
if (! str_ends_with($tokenUrl, '/token')) {
|
||||
throw new RuntimeException('Parcel2Go token URL must end in /token.');
|
||||
}
|
||||
|
||||
return substr($tokenUrl, 0, -strlen('/token')).'/authorize';
|
||||
}
|
||||
|
||||
private function profileAccessToken(): string
|
||||
{
|
||||
if ($this->profileToken !== null) {
|
||||
return $this->profileToken;
|
||||
}
|
||||
$record = IntegrationSetting::find('parcel2go_oauth');
|
||||
$connection = $record?->settings ?? [];
|
||||
if (empty($connection['access_token']) || ($connection['client_id'] ?? null) !== ($this->settings->for('parcel2go')['profile_client_id'] ?? null)) {
|
||||
throw new RuntimeException('Parcel2Go account connection is missing or belongs to another API client.');
|
||||
}
|
||||
if (Carbon::parse($connection['expires_at'] ?? null)->isFuture()) {
|
||||
return $this->profileToken = $connection['access_token'];
|
||||
}
|
||||
if (empty($connection['refresh_token'])) {
|
||||
throw new RuntimeException('Parcel2Go account access expired. Reconnect the account.');
|
||||
}
|
||||
|
||||
$tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10)
|
||||
->post($this->settings->for('parcel2go')['auth_url'], [
|
||||
'grant_type' => 'refresh_token',
|
||||
'client_id' => $connection['client_id'],
|
||||
'refresh_token' => $connection['refresh_token'],
|
||||
])->throw()->json();
|
||||
if (empty($tokens['access_token'])) {
|
||||
throw new RuntimeException('Parcel2Go did not refresh account access.');
|
||||
}
|
||||
$record->settings = array_replace($connection, [
|
||||
'access_token' => $tokens['access_token'],
|
||||
'refresh_token' => $tokens['refresh_token'] ?? $connection['refresh_token'],
|
||||
'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60))->toIso8601String(),
|
||||
]);
|
||||
$record->save();
|
||||
|
||||
return $this->profileToken = $tokens['access_token'];
|
||||
}
|
||||
|
||||
private function accessToken(): string
|
||||
{
|
||||
if ($this->token !== null) {
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<label class="grid gap-2 text-sm text-slate-300">OAuth token URL<input name="auth_url" type="url" maxlength="2048" value="{{ old('auth_url', $integrations['parcel2go']['auth_url']) }}" placeholder="https://…" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-2 text-sm text-slate-300">API base URL<input name="base_url" type="url" maxlength="2048" value="{{ old('base_url', $integrations['parcel2go']['base_url']) }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-2 text-sm text-slate-300">Collection postcode<input name="collection_postcode" maxlength="12" value="{{ old('collection_postcode', $integrations['parcel2go']['collection_postcode']) }}" placeholder="Used for Etsy order shipping quotes" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100"></label>
|
||||
<label class="grid gap-2 text-sm text-slate-300">Authorization Code Client ID<input name="profile_client_id" type="password" autocomplete="new-password" maxlength="255" placeholder="{{ $integrations['parcel2go']['profile_client_configured'] ? 'Saved — leave blank to keep it' : 'Enter the new Authorization Code Client ID' }}" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100 placeholder:text-slate-500"></label>
|
||||
<label class="grid gap-2 text-sm text-slate-300">API access
|
||||
<select name="scope" class="rounded-xl border border-slate-700 bg-slate-950 px-4 py-3 text-slate-100">
|
||||
@foreach(['public-api' => 'Shipping quotes and collections', 'public-api my-profile' => 'Shipping and account history', 'public-api payment' => 'Shipping and PrePay payments', 'public-api payment my-profile' => 'Shipping, account history and PrePay'] as $scope => $label)
|
||||
@@ -63,9 +64,16 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<p class="text-xs text-slate-500">To request account history, sign in to Parcel2Go My Account → Settings → API and check this client's permissions. If <code>my-profile</code> is unavailable, email <a class="text-brand hover:underline" href="mailto:apihelp@parcel2go.com">apihelp@parcel2go.com</a> with your account email and Client ID. Ask them to enable <code>my-profile</code> for your live client. Never email the Client secret. After confirmation, select “Shipping and account history” here.</p>
|
||||
<p class="text-xs text-slate-500">Keep basic <code>public-api</code> access for quotes and bookings. The separate Authorization Code client connects your own account for shipment history and labels.</p>
|
||||
<button class="w-fit rounded-xl bg-brand px-5 py-3 text-sm font-semibold text-slate-950 hover:bg-brand/90">Save Parcel2Go</button>
|
||||
</form>
|
||||
<div class="mt-5 grid gap-3 rounded-xl border border-slate-700 bg-slate-950 p-4 text-sm">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2"><p class="font-semibold">Parcel2Go account consent</p><span @class(['rounded-full px-2 py-1 text-xs font-semibold', 'bg-emerald-500/10 text-emerald-300' => $integrations['parcel2go']['profile_connected'], 'bg-amber-500/10 text-amber-200' => ! $integrations['parcel2go']['profile_connected']])>{{ $integrations['parcel2go']['profile_connected'] ? 'Connected' : 'Not connected' }}</span></div>
|
||||
<p class="text-slate-400">Create an <strong>Authorization Code</strong> client in Parcel2Go. Set Application URL to <code>{{ url('/') }}</code> and Redirect URI to this exact address:</p>
|
||||
<code class="overflow-x-auto rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-slate-200">{{ $integrations['parcel2go']['callback_url'] }}</code>
|
||||
<p class="text-slate-400">Save its Client ID above, then sign in and approve <code>my-profile</code> access. The account tokens are encrypted in the database. If consent rejects this scope, contact <a class="text-brand hover:underline" href="mailto:apihelp@parcel2go.com">apihelp@parcel2go.com</a>.</p>
|
||||
@if($integrations['parcel2go']['profile_client_configured'])<form method="post" action="{{ route('parcel2go.connect') }}">@csrf<button class="rounded-xl border border-brand/40 px-4 py-2 font-semibold text-brand hover:bg-brand/10">{{ $integrations['parcel2go']['profile_connected'] ? 'Reconnect Parcel2Go account' : 'Connect Parcel2Go account' }}</button></form>@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\DryerController;
|
||||
use App\Http\Controllers\EtsyController;
|
||||
use App\Http\Controllers\EtsySettingsController;
|
||||
use App\Http\Controllers\IntegrationSettingsController;
|
||||
use App\Http\Controllers\Parcel2GoConnectionController;
|
||||
use App\Http\Controllers\PrinterController;
|
||||
use App\Http\Controllers\ShippingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
@@ -29,6 +30,8 @@ Route::middleware(['auth', EnsureAccountActive::class])->group(function (): void
|
||||
Route::post('/shipping/collections/book', [ShippingController::class, 'bookCollection'])->name('shipping.book-collection');
|
||||
Route::post('/shipping/collections/reset', [ShippingController::class, 'resetBooking'])->name('shipping.reset-booking');
|
||||
Route::get('/shipping/labels/{orderLineId}', [ShippingController::class, 'label'])->name('shipping.label');
|
||||
Route::post('/shipping/parcel2go/connect', [Parcel2GoConnectionController::class, 'connect'])->name('parcel2go.connect');
|
||||
Route::get('/shipping/parcel2go/callback', [Parcel2GoConnectionController::class, 'callback'])->name('parcel2go.callback');
|
||||
Route::get('/etsy', [EtsyController::class, 'index'])->name('etsy.index');
|
||||
Route::post('/etsy/connect', [EtsyController::class, 'connect'])->name('etsy.connect');
|
||||
Route::get('/etsy/callback', [EtsyController::class, 'callback'])->name('etsy.callback');
|
||||
|
||||
@@ -108,9 +108,11 @@ class IntegrationSettingsTest extends TestCase
|
||||
$this->assertDatabaseCount('integration_settings', 0);
|
||||
|
||||
$this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [
|
||||
'scope' => 'public-api my-profile', 'collection_postcode' => 'SW1A 1AA',
|
||||
'scope' => 'public-api my-profile', 'collection_postcode' => 'SW1A 1AA', 'profile_client_id' => 'profile-client',
|
||||
])->assertRedirect(route('settings.index'));
|
||||
$this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']);
|
||||
$this->assertSame('SW1A 1AA', IntegrationSetting::findOrFail('parcel2go')->settings['collection_postcode']);
|
||||
$this->assertSame('profile-client', IntegrationSetting::findOrFail('parcel2go')->settings['profile_client_id']);
|
||||
$this->actingAs($admin)->get(route('settings.index'))->assertOk()->assertDontSee('profile-client');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\IntegrationSetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Parcel2Go;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class Parcel2GoOAuthTest extends TestCase
|
||||
{
|
||||
use LazilyRefreshDatabase;
|
||||
|
||||
public function test_only_an_administrator_can_start_account_consent(): void
|
||||
{
|
||||
$this->configureParcel2Go();
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->post(route('parcel2go.connect'))->assertRedirect(route('login'));
|
||||
$this->actingAs(User::factory()->create())->post(route('parcel2go.connect'))->assertForbidden();
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_consent_uses_pkce_and_exact_callback_url(): void
|
||||
{
|
||||
$this->configureParcel2Go();
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$response = $this->actingAs(User::factory()->create(['role' => 'admin']))->post(route('parcel2go.connect'));
|
||||
$query = [];
|
||||
parse_str(parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
|
||||
$pending = session('parcel2go_oauth');
|
||||
|
||||
$this->assertSame('https://auth.example.test/connect/authorize', strtok($response->headers->get('Location'), '?'));
|
||||
$this->assertSame('profile-client', $query['client_id']);
|
||||
$this->assertSame(route('parcel2go.callback'), $query['redirect_uri']);
|
||||
$this->assertSame('public-api my-profile offline_access', $query['scope']);
|
||||
$this->assertSame('S256', $query['code_challenge_method']);
|
||||
$this->assertSame($pending['state'], $query['state']);
|
||||
$this->assertSame(rtrim(strtr(base64_encode(hash('sha256', $pending['verifier'], true)), '+/', '-_'), '='), $query['code_challenge']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_callback_rejects_bad_state_without_exchanging_a_code(): void
|
||||
{
|
||||
$this->configureParcel2Go();
|
||||
Http::preventStrayRequests();
|
||||
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->withSession(['parcel2go_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]])
|
||||
->get(route('parcel2go.callback', ['state' => 'wrong', 'code' => 'code']))
|
||||
->assertRedirect(route('settings.index'))->assertSessionHasErrors('parcel2go');
|
||||
$this->assertDatabaseMissing('integration_settings', ['provider' => 'parcel2go_oauth']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_callback_stores_encrypted_tokens_and_uses_them_for_account_history(): void
|
||||
{
|
||||
$this->configureParcel2Go();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://auth.example.test/connect/token' => Http::response([
|
||||
'access_token' => 'profile-token', 'refresh_token' => 'refresh-token', 'expires_in' => 3600,
|
||||
]),
|
||||
'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => [['OrderLineId' => 123]]]),
|
||||
]);
|
||||
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->withSession(['parcel2go_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]])
|
||||
->get(route('parcel2go.callback', ['state' => 'expected', 'code' => 'code']))
|
||||
->assertRedirect(route('settings.index'))->assertSessionHas('status');
|
||||
$connection = IntegrationSetting::findOrFail('parcel2go_oauth');
|
||||
$this->assertSame('profile-token', $connection->settings['access_token']);
|
||||
$this->assertStringNotContainsString('profile-token', $connection->getRawOriginal('settings'));
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/connect/token'
|
||||
&& $request['grant_type'] === 'authorization_code' && $request['code_verifier'] === 'verifier');
|
||||
$this->assertSame([['OrderLineId' => 123]], app(Parcel2Go::class)->fetch('me/orders/detail')['Orders']);
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/orders/detail'
|
||||
&& $request->hasHeader('Authorization', 'Bearer profile-token'));
|
||||
}
|
||||
|
||||
public function test_expired_account_token_is_refreshed_before_history_request(): void
|
||||
{
|
||||
$this->configureParcel2Go();
|
||||
IntegrationSetting::create(['provider' => 'parcel2go_oauth', 'settings' => [
|
||||
'client_id' => 'profile-client', 'access_token' => 'old-token',
|
||||
'refresh_token' => 'old-refresh', 'expires_at' => now()->subMinute()->toIso8601String(),
|
||||
]]);
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://auth.example.test/connect/token' => Http::response([
|
||||
'access_token' => 'new-token', 'refresh_token' => 'new-refresh', 'expires_in' => 3600,
|
||||
]),
|
||||
'https://shipping.example.test/api/me/orders/detail' => Http::response(['Orders' => []]),
|
||||
]);
|
||||
|
||||
$this->assertSame([], app(Parcel2Go::class)->fetch('me/orders/detail')['Orders']);
|
||||
$this->assertSame('new-refresh', IntegrationSetting::findOrFail('parcel2go_oauth')->settings['refresh_token']);
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://auth.example.test/connect/token'
|
||||
&& $request['grant_type'] === 'refresh_token' && $request['refresh_token'] === 'old-refresh');
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://shipping.example.test/api/me/orders/detail'
|
||||
&& $request->hasHeader('Authorization', 'Bearer new-token'));
|
||||
}
|
||||
|
||||
public function test_callback_does_not_save_a_token_without_history_permission(): void
|
||||
{
|
||||
$this->configureParcel2Go();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://auth.example.test/connect/token' => Http::response(['access_token' => 'limited-token', 'expires_in' => 3600]),
|
||||
'https://shipping.example.test/api/me/orders/detail' => Http::response([], 403),
|
||||
]);
|
||||
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->withSession(['parcel2go_oauth' => ['state' => 'expected', 'verifier' => 'verifier', 'started' => time()]])
|
||||
->get(route('parcel2go.callback', ['state' => 'expected', 'code' => 'code']))
|
||||
->assertRedirect(route('settings.index'))->assertSessionHasErrors('parcel2go');
|
||||
$this->assertDatabaseMissing('integration_settings', ['provider' => 'parcel2go_oauth']);
|
||||
}
|
||||
|
||||
private function configureParcel2Go(): void
|
||||
{
|
||||
config()->set('services.parcel2go', [
|
||||
'client_id' => 'basic-client', 'client_secret' => 'basic-secret',
|
||||
'profile_client_id' => 'profile-client',
|
||||
'auth_url' => 'https://auth.example.test/connect/token',
|
||||
'base_url' => 'https://shipping.example.test/api',
|
||||
'scope' => 'public-api',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user