[verified] Harden Etsy MCP validation and scheduled token refresh
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
This commit is contained in:
@@ -6,6 +6,7 @@ use App\Console\Commands\EtsyMcp;
|
||||
use App\Models\EtsyConnection;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\BufferedOutput;
|
||||
use Tests\TestCase;
|
||||
@@ -130,6 +131,7 @@ class EtsyMcpTest extends TestCase
|
||||
Http::assertNothingSent();
|
||||
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings/99' => Http::response([
|
||||
'listing_id' => 99, 'state' => 'active', 'shop_id' => 42, 'title' => 'Widget',
|
||||
]),
|
||||
@@ -143,6 +145,68 @@ class EtsyMcpTest extends TestCase
|
||||
&& $request['state'] === 'active');
|
||||
}
|
||||
|
||||
#[TestWith(['etsy_update_listing', ['title' => 'Wrong shop']])]
|
||||
#[TestWith(['etsy_publish_listing', ['confirm' => true]])]
|
||||
public function test_listing_mutations_verify_ownership_before_patch(string $tool, array $payload): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake([
|
||||
'*/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 7]),
|
||||
'*/shops/42/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]),
|
||||
]);
|
||||
$result = $this->mcpCall($tool, ['listing_id' => 99, ...$payload]);
|
||||
$this->assertTrue($result['isError']);
|
||||
Http::assertNotSent(fn ($request): bool => $request->method() === 'PATCH');
|
||||
}
|
||||
|
||||
#[TestWith([0])]
|
||||
#[TestWith([-1])]
|
||||
#[TestWith([1.5])]
|
||||
#[TestWith(['99'])]
|
||||
public function test_listing_ids_must_be_positive_integers_before_http(mixed $listingId): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]);
|
||||
|
||||
$result = $this->mcpCall('etsy_get_listing', ['listing_id' => $listingId]);
|
||||
|
||||
$this->assertTrue($result['isError']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_update_listing_rejects_unknown_fields_before_http(): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]);
|
||||
$result = $this->mcpCall('etsy_update_listing', ['listing_id' => 99, 'unexpected_field' => true]);
|
||||
$this->assertTrue($result['isError']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[TestWith(['price', 12.5])]
|
||||
#[TestWith(['quantity', 3])]
|
||||
#[TestWith(['who_made', 'i_did'])]
|
||||
#[TestWith(['when_made', 'made_to_order'])]
|
||||
#[TestWith(['shipping_profile_id', 123])]
|
||||
#[TestWith(['materials', ['PLA']])]
|
||||
#[TestWith(['type', 'physical'])]
|
||||
#[TestWith(['shop_id', 42])]
|
||||
public function test_update_listing_rejects_undeclared_fields_before_http(string $field, mixed $value): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]),
|
||||
'https://api.etsy.com/v3/application/shops/42/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]),
|
||||
]);
|
||||
|
||||
$result = $this->mcpCall('etsy_update_listing', ['listing_id' => 99, 'title' => 'Widget', $field => $value]);
|
||||
|
||||
$this->assertTrue($result['isError']);
|
||||
$this->assertStringContainsString('Invalid listing payload:', $result['data']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_update_listing_rejects_publishing_without_the_publish_tool(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
@@ -202,10 +266,54 @@ class EtsyMcpTest extends TestCase
|
||||
&& $request['carrier_name'] === 'evri');
|
||||
}
|
||||
|
||||
#[TestWith(['etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => -1]])]
|
||||
#[TestWith(['etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => 1.5]])]
|
||||
#[TestWith(['etsy_update_listing_inventory', ['listing_id' => 99, 'products' => [['offerings' => 'invalid']]]])]
|
||||
#[TestWith(['etsy_publish_listing', ['listing_id' => 99, 'confirm' => 'yes']])]
|
||||
#[TestWith(['etsy_mark_receipt_shipped', ['receipt_id' => 15, 'tracking_code' => 'ABC', 'carrier_name' => 'evri', 'unexpected' => true]])]
|
||||
public function test_mutation_schema_violations_are_rejected_before_http(string $tool, array $arguments): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]);
|
||||
|
||||
$result = $this->mcpCall($tool, $arguments);
|
||||
|
||||
$this->assertTrue($result['isError']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_quantity_shortcut_preserves_inventory_variation_metadata(): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]),
|
||||
'https://api.etsy.com/v3/application/listings/99/inventory' => fn ($request) => $request->method() === 'GET'
|
||||
? Http::response([
|
||||
'price_on_property' => [100], 'quantity_on_property' => [200], 'sku_on_property' => [100, 200], 'readiness_state_on_property' => [200],
|
||||
'products' => [['sku' => 'HAT', 'property_values' => [], 'offerings' => [['quantity' => 1, 'price' => ['amount' => 1250, 'divisor' => 100], 'is_enabled' => true, 'readiness_state_id' => 18201076875]]]],
|
||||
])
|
||||
: Http::response(['products' => []]),
|
||||
]);
|
||||
|
||||
$result = $this->mcpCall('etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => 0]);
|
||||
|
||||
$this->assertFalse($result['isError']);
|
||||
Http::assertSent(fn ($request) => $request->method() === 'PUT'
|
||||
&& ($request->data()['price_on_property'] ?? null) === [100]
|
||||
&& $request['quantity_on_property'] === [200]
|
||||
&& $request['sku_on_property'] === [100, 200]
|
||||
&& ($request->data()['readiness_state_on_property'] ?? null) === [200]
|
||||
&& ($request['products'][0]['offerings'][0]['readiness_state_id'] ?? null) === 18201076875
|
||||
&& $request['products'][0]['offerings'][0]['quantity'] === 0
|
||||
&& $request['products'][0]['offerings'][0]['price'] === 12.5);
|
||||
}
|
||||
|
||||
public function test_inventory_update_and_receipt_messages_go_through_the_service(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://api.etsy.com/v3/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => 42]),
|
||||
'https://api.etsy.com/v3/application/listings/99/inventory' => function ($request) {
|
||||
return $request->method() === 'PUT'
|
||||
? Http::response(['products' => [['sku' => 'HAT', 'offerings' => [['quantity' => 4, 'price' => 12.5, 'is_enabled' => true]]]]])
|
||||
@@ -271,6 +379,49 @@ class EtsyMcpTest extends TestCase
|
||||
$this->assertStringContainsString('reconnect', strtolower($encoded));
|
||||
}
|
||||
|
||||
#[TestWith([7])]
|
||||
#[TestWith([null])]
|
||||
public function test_inventory_access_is_denied_for_another_shop_before_any_inventory_request(?int $shopId): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'*/application/listings/99' => Http::response(['listing_id' => 99, 'shop_id' => $shopId]),
|
||||
'*/inventory' => Http::response(['products' => []]),
|
||||
]);
|
||||
$read = $this->mcpCall('etsy_get_listing_inventory', ['listing_id' => 99]);
|
||||
$this->assertTrue($read['isError']);
|
||||
$write = $this->mcpCall('etsy_update_listing_inventory', ['listing_id' => 99, 'products' => []]);
|
||||
$this->assertTrue($write['isError']);
|
||||
Http::assertNotSent(fn ($request): bool => str_ends_with($request->url(), '/inventory'));
|
||||
}
|
||||
|
||||
public function test_mutation_with_missing_scopes_requires_reconnect_without_calling_etsy(): void
|
||||
{
|
||||
$this->connectShop('transactions_r listings_r');
|
||||
Http::fake();
|
||||
$result = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99, 'confirm' => true]);
|
||||
$this->assertTrue($result['isError']);
|
||||
$this->assertStringContainsString('reconnect', strtolower($result['data']));
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[TestWith(['quantity', -1])]
|
||||
#[TestWith(['price', -5])]
|
||||
#[TestWith(['who_made', 'invalid'])]
|
||||
#[TestWith(['unexpected_field', true])]
|
||||
public function test_invalid_draft_payload_is_rejected_before_http(string $field, mixed $value): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake(['*' => Http::response(['listing_id' => 99, 'shop_id' => 42])]);
|
||||
$payload = ['title' => 'Widget', 'description' => 'Printed widget', 'quantity' => 1, 'price' => 5,
|
||||
'who_made' => 'i_did', 'when_made' => 'made_to_order', 'taxonomy_id' => 1];
|
||||
$payload[$field] = $value;
|
||||
$result = $this->mcpCall('etsy_create_draft_listing', $payload);
|
||||
$this->assertTrue($result['isError']);
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_wrong_shop_name_is_rejected_without_calling_etsy(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Models\EtsyAppCredential;
|
||||
use App\Models\EtsyConnection;
|
||||
use App\Services\Etsy;
|
||||
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -22,6 +23,16 @@ class EtsySyncScheduledJobsTest extends TestCase
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_bundled_refresh_does_not_duplicate_the_independently_scheduled_etsy_sync(): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake();
|
||||
|
||||
$this->artisan('integrations:refresh')->assertExitCode(0);
|
||||
|
||||
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'etsy.com'));
|
||||
}
|
||||
|
||||
public function test_etsy_sync_refreshes_receipts_and_listings(): void
|
||||
{
|
||||
$this->connectShop();
|
||||
@@ -38,6 +49,28 @@ class EtsySyncScheduledJobsTest extends TestCase
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_etsy_sync_returns_failure_if_a_required_refresh_fails(): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake([
|
||||
'*/receipts*' => Http::response([], 503),
|
||||
'*/listings*' => Http::response(['results' => []]),
|
||||
]);
|
||||
$this->artisan('etsy:sync')->assertExitCode(1);
|
||||
Http::assertSentCount(2);
|
||||
}
|
||||
|
||||
public function test_etsy_sync_reports_failure_instead_of_claiming_stale_cache_was_refreshed(): void
|
||||
{
|
||||
$this->connectShop();
|
||||
Http::fake([
|
||||
'*/receipts*' => Http::sequence()->push(['results' => []])->push([], 503),
|
||||
'*/listings*' => Http::sequence()->push(['results' => []])->push([], 503),
|
||||
]);
|
||||
$this->artisan('etsy:sync')->assertExitCode(0);
|
||||
$this->artisan('etsy:sync')->assertExitCode(1);
|
||||
}
|
||||
|
||||
public function test_etsy_refresh_token_skips_cleanly_when_shop_is_not_connected(): void
|
||||
{
|
||||
Http::fake(fn () => Http::response([], 500));
|
||||
@@ -78,6 +111,23 @@ class EtsySyncScheduledJobsTest extends TestCase
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_refreshes_before_expiry_and_does_not_reuse_a_stale_rotating_token(): void
|
||||
{
|
||||
$this->freezeTime();
|
||||
$connection = $this->connectShop(expiresAt: now()->addMinutes(8));
|
||||
$stale = EtsyConnection::findOrFail($connection->id);
|
||||
Http::preventStrayRequests();
|
||||
Http::fake(['*/oauth/token' => Http::response([
|
||||
'access_token' => 'rotated-access', 'refresh_token' => 'rotated-refresh', 'expires_in' => 3600,
|
||||
])]);
|
||||
|
||||
$etsy = app(Etsy::class);
|
||||
$this->assertSame('rotated-access', $etsy->refreshAccessToken($connection));
|
||||
$this->assertSame('rotated-access', $etsy->refreshAccessToken($stale));
|
||||
Http::assertSentCount(1);
|
||||
$this->assertSame('rotated-refresh', $connection->fresh()->refresh_token);
|
||||
}
|
||||
|
||||
private function connectShop(?Carbon $expiresAt = null): EtsyConnection
|
||||
{
|
||||
EtsyAppCredential::create([
|
||||
|
||||
@@ -70,6 +70,29 @@ class ScheduleSettingsTest extends TestCase
|
||||
$this->assertFileExists(DashboardSchedule::runtimeSettingsPath());
|
||||
}
|
||||
|
||||
public function test_partial_settings_updates_preserve_other_jobs(): void
|
||||
{
|
||||
DashboardSchedule::persistRuntimeFrequencies(['alerts_scan' => 'hourly']);
|
||||
DashboardSchedule::persistRuntimeFrequencies(['etsy_sync' => 'disabled']);
|
||||
|
||||
$this->assertSame('hourly', DashboardSchedule::frequencyFor('alerts_scan'));
|
||||
$this->assertSame('disabled', DashboardSchedule::frequencyFor('etsy_sync'));
|
||||
}
|
||||
|
||||
public function test_admin_cannot_save_unknown_jobs(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create(['role' => 'admin']))
|
||||
->put(route('settings.schedules.update'), ['schedules' => ['unknown' => 'hourly']])
|
||||
->assertSessionHasErrors('schedules');
|
||||
$this->assertFileDoesNotExist(DashboardSchedule::runtimeSettingsPath());
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_run_jobs(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create())
|
||||
->post(route('settings.schedules.run', 'etsy_sync'))->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_admin_can_manually_run_a_job(): void
|
||||
{
|
||||
$response = $this
|
||||
|
||||
@@ -67,7 +67,24 @@ class SchedulerTest extends TestCase
|
||||
$this->assertNotNull($sync);
|
||||
$this->assertNotNull($tokenRefresh);
|
||||
$this->assertSame('*/15 * * * *', $sync->expression);
|
||||
$this->assertSame('*/45 * * * *', $tokenRefresh->expression);
|
||||
$this->assertSame('*/5 * * * *', $tokenRefresh->expression);
|
||||
}
|
||||
|
||||
public function test_etsy_stale_overlap_locks_expire_within_five_minutes(): void
|
||||
{
|
||||
$this->freezeTime();
|
||||
$events = collect(app(Schedule::class)->events())
|
||||
->filter(fn ($event): bool => str_contains((string) $event->command, 'etsy:'));
|
||||
|
||||
foreach ($events as $event) {
|
||||
$this->assertTrue($event->mutex->create($event));
|
||||
$this->assertTrue($event->mutex->exists($event));
|
||||
}
|
||||
$this->travel(6)->minutes();
|
||||
|
||||
foreach ($events as $event) {
|
||||
$this->assertFalse($event->mutex->exists($event));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_changing_a_stored_frequency_changes_what_gets_scheduled(): void
|
||||
|
||||
Reference in New Issue
Block a user