Add Parcel2Go account consent with PKCE
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 26s

This commit is contained in:
Hickmeister
2026-10-11 17:27:07 +01:00
parent 1c80811713
commit 1be66f099c
9 changed files with 321 additions and 9 deletions
@@ -7,13 +7,14 @@ use App\Models\EtsyConnection;
use App\Models\IntegrationSetting; use App\Models\IntegrationSetting;
use App\Services\Etsy; use App\Services\Etsy;
use App\Services\IntegrationSettings; use App\Services\IntegrationSettings;
use App\Services\Parcel2Go;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
class EtsySettingsController extends Controller 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); abort_unless($request->user()->role === 'admin', 403);
@@ -37,6 +38,9 @@ class EtsySettingsController extends Controller
'base_url' => $parcel2go['base_url'] ?? '', 'base_url' => $parcel2go['base_url'] ?? '',
'scope' => $parcel2go['scope'] ?? 'public-api', 'scope' => $parcel2go['scope'] ?? 'public-api',
'collection_postcode' => $parcel2go['collection_postcode'] ?? '', 'collection_postcode' => $parcel2go['collection_postcode'] ?? '',
'profile_client_configured' => $parcel2Go->profileClientConfigured(),
'profile_connected' => $parcel2Go->profileConnected(),
'callback_url' => route('parcel2go.callback'),
], ],
'bambuddy' => [ 'bambuddy' => [
'stored' => in_array('bambuddy', $storedIntegrations, true), 'stored' => in_array('bambuddy', $storedIntegrations, true),
@@ -22,6 +22,7 @@ class IntegrationSettingsController extends Controller
'base_url' => ['nullable', 'url:https', 'max:2048'], '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'], 'scope' => ['nullable', 'string', 'in:public-api,public-api my-profile,public-api payment,public-api payment my-profile'],
'collection_postcode' => ['nullable', 'string', 'max:12'], 'collection_postcode' => ['nullable', 'string', 'max:12'],
'profile_client_id' => ['nullable', 'string', 'max:255'],
], ],
default => [ default => [
'url' => ['nullable', 'url:https', 'max:2048'], '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.']);
}
}
}
+6 -4
View File
@@ -42,11 +42,13 @@ class ShippingController extends Controller
$orders = $parcel2Go->fetch('me/orders/detail'); $orders = $parcel2Go->fetch('me/orders/detail');
$historyAvailable = true; $historyAvailable = true;
} catch (Throwable $exception) { } catch (Throwable $exception) {
$profileNotice = $exception instanceof RequestException && in_array($exception->response->status(), [401, 403], true) $profileNotice = $parcel2Go->profileConnected()
? 'Parcel2Go denied shipment history for this client. Request my-profile access from Parcel2Go API support.' ? 'Parcel2Go account access needs attention. Reconnect the account in Settings.'
: 'Parcel2Go shipment history is temporarily unavailable.'; : ($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 { try {
$account = $parcel2Go->fetch('me/detail'); $account = $parcel2Go->fetch('me/detail');
$balance = $parcel2Go->fetch('me/prepay/balance'); $balance = $parcel2Go->fetch('me/prepay/balance');
+114 -2
View File
@@ -2,7 +2,9 @@
namespace App\Services; namespace App\Services;
use App\Models\IntegrationSetting;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use RuntimeException; use RuntimeException;
@@ -15,6 +17,8 @@ class Parcel2Go
private bool $reducedScope = false; private bool $reducedScope = false;
private ?string $profileToken = null;
public function __construct(private IntegrationSettings $settings) {} public function __construct(private IntegrationSettings $settings) {}
public function hasScope(string $scope): bool public function hasScope(string $scope): bool
@@ -32,9 +36,70 @@ class Parcel2Go
$this->accessToken(); $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 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() ?? []; ->get($this->url($path))->throw()->json() ?? [];
} }
@@ -98,7 +163,8 @@ class Parcel2Go
public function labelPdf(string $reference, string $hash): string 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), [ ->get($this->url('labels/'.$reference), [
'referenceType' => 'OrderLineId', 'detailLevel' => 'Labels', 'referenceType' => 'OrderLineId', 'detailLevel' => 'Labels',
'labelMedia' => 'A4', 'labelFormat' => 'PDF', 'hash' => $hash, 'labelMedia' => 'A4', 'labelFormat' => 'PDF', 'hash' => $hash,
@@ -117,6 +183,52 @@ class Parcel2Go
return rtrim($this->settings->for('parcel2go')['base_url'], '/').'/'.ltrim($path, '/'); 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 private function accessToken(): string
{ {
if ($this->token !== null) { if ($this->token !== null) {
+9 -1
View File
@@ -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">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">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">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 <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"> <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) @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 @endforeach
</select> </select>
</label> </label>
<p class="text-xs text-slate-500">To request account history, sign in to Parcel2Go My Account &rarr; Settings &rarr; 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> <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> </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>
<section class="rounded-2xl border border-slate-800 bg-slate-900 p-6"> <section class="rounded-2xl border border-slate-800 bg-slate-900 p-6">
+3
View File
@@ -7,6 +7,7 @@ use App\Http\Controllers\DryerController;
use App\Http\Controllers\EtsyController; use App\Http\Controllers\EtsyController;
use App\Http\Controllers\EtsySettingsController; use App\Http\Controllers\EtsySettingsController;
use App\Http\Controllers\IntegrationSettingsController; use App\Http\Controllers\IntegrationSettingsController;
use App\Http\Controllers\Parcel2GoConnectionController;
use App\Http\Controllers\PrinterController; use App\Http\Controllers\PrinterController;
use App\Http\Controllers\ShippingController; use App\Http\Controllers\ShippingController;
use App\Http\Controllers\UserController; 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/book', [ShippingController::class, 'bookCollection'])->name('shipping.book-collection');
Route::post('/shipping/collections/reset', [ShippingController::class, 'resetBooking'])->name('shipping.reset-booking'); Route::post('/shipping/collections/reset', [ShippingController::class, 'resetBooking'])->name('shipping.reset-booking');
Route::get('/shipping/labels/{orderLineId}', [ShippingController::class, 'label'])->name('shipping.label'); 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::get('/etsy', [EtsyController::class, 'index'])->name('etsy.index');
Route::post('/etsy/connect', [EtsyController::class, 'connect'])->name('etsy.connect'); Route::post('/etsy/connect', [EtsyController::class, 'connect'])->name('etsy.connect');
Route::get('/etsy/callback', [EtsyController::class, 'callback'])->name('etsy.callback'); Route::get('/etsy/callback', [EtsyController::class, 'callback'])->name('etsy.callback');
+3 -1
View File
@@ -108,9 +108,11 @@ class IntegrationSettingsTest extends TestCase
$this->assertDatabaseCount('integration_settings', 0); $this->assertDatabaseCount('integration_settings', 0);
$this->actingAs($admin)->put(route('settings.integrations.update', 'parcel2go'), [ $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')); ])->assertRedirect(route('settings.index'));
$this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']); $this->assertSame('public-api my-profile', IntegrationSetting::findOrFail('parcel2go')->settings['scope']);
$this->assertSame('SW1A 1AA', IntegrationSetting::findOrFail('parcel2go')->settings['collection_postcode']); $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');
} }
} }
+133
View File
@@ -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',
]);
}
}