[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();
|
||||
|
||||
Reference in New Issue
Block a user