Files
hermes c8c5822166
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s
[verified] Harden Etsy MCP validation and scheduled token refresh
2026-11-07 21:54:33 +00:00

521 lines
23 KiB
PHP

<?php
namespace Tests\Feature;
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;
class EtsyMcpTest extends TestCase
{
use LazilyRefreshDatabase;
public function test_tools_list_is_served_over_stdio_jsonrpc(): void
{
$response = $this->mcp(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$this->assertSame('2.0', $response['jsonrpc']);
$this->assertSame(1, $response['id']);
$names = array_column($response['result']['tools'], 'name');
$this->assertSame([
'etsy_shop_status',
'etsy_list_listings',
'etsy_get_listing',
'etsy_create_draft_listing',
'etsy_update_listing',
'etsy_publish_listing',
'etsy_get_listing_inventory',
'etsy_update_listing_inventory',
'etsy_list_receipts',
'etsy_get_receipt',
'etsy_mark_receipt_shipped',
'etsy_list_receipt_messages',
'etsy_get_receipt_messages',
], $names);
$this->assertNotContains('etsy_delete_listing', $names);
$this->assertNotContains('etsy_send_message', $names);
}
public function test_initialize_and_initialized_notification(): void
{
$response = $this->mcp([
'jsonrpc' => '2.0',
'id' => 'init-1',
'method' => 'initialize',
'params' => ['protocolVersion' => '2024-11-05', 'capabilities' => new \stdClass, 'clientInfo' => ['name' => 'test']],
]);
$this->assertSame('2024-11-05', $response['result']['protocolVersion']);
$this->assertSame('etsy', $response['result']['serverInfo']['name']);
$this->assertArrayHasKey('tools', $response['result']['capabilities']);
$this->assertNull($this->mcpRaw(['jsonrpc' => '2.0', 'method' => 'notifications/initialized']));
}
public function test_shop_status_reports_disconnected_shop_without_tokens(): void
{
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
$result = $this->mcpCall('etsy_shop_status');
$this->assertFalse($result['isError']);
$this->assertTrue($result['data']['configured']);
$this->assertFalse($result['data']['connected']);
$this->assertSame('HickLab3D', $result['data']['configured_shop_name']);
$this->assertContains('listings_w', $result['data']['required_scopes']);
$this->assertStringNotContainsString('key', json_encode($result['rpc']));
$this->assertStringNotContainsString('secret', json_encode($result['rpc']));
$this->assertArrayNotHasKey('access_token', $result['data']);
$this->assertArrayNotHasKey('refresh_token', $result['data']);
}
public function test_shop_status_asks_admin_to_reconnect_when_scopes_are_missing(): void
{
$this->connectShop('transactions_r listings_r');
$result = $this->mcpCall('etsy_shop_status');
$this->assertTrue($result['data']['connected']);
$this->assertTrue($result['data']['reconnect_required']);
$this->assertSame(42, $result['data']['shop_id']);
$this->assertSame('HickLab3D', $result['data']['shop_name']);
$this->assertContains('listings_w', $result['data']['missing_scopes']);
$this->assertStringContainsString('reconnect', strtolower($result['data']['reconnect_reason']));
$this->assertArrayNotHasKey('access_token', $result['data']);
}
public function test_create_draft_listing_posts_a_draft_and_never_publishes(): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.etsy.com/v3/application/shops/42/listings' => Http::response([
'listing_id' => 99, 'title' => 'Widget', 'state' => 'draft', 'quantity' => 3, 'shop_id' => 42,
], 201),
]);
$this->connectShop();
$result = $this->mcpCall('etsy_create_draft_listing', [
'title' => 'Widget',
'description' => 'A printed widget',
'price' => 12.5,
'quantity' => 3,
'who_made' => 'i_did',
'when_made' => 'made_to_order',
'taxonomy_id' => 1234,
'state' => 'active',
]);
$this->assertFalse($result['isError']);
$this->assertSame(99, $result['data']['listing_id']);
$this->assertSame('draft', $result['data']['state']);
Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/listings'
&& $request->method() === 'POST'
&& $request['title'] === 'Widget'
&& (int) $request['quantity'] === 3
&& ! array_key_exists('state', $request->data()));
Http::assertSentCount(1);
}
public function test_publish_listing_requires_confirm_and_patches_active_when_given(): void
{
Http::preventStrayRequests();
$this->connectShop();
$denied = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99]);
$this->assertTrue($denied['isError']);
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',
]),
]);
$published = $this->mcpCall('etsy_publish_listing', ['listing_id' => 99, 'confirm' => true]);
$this->assertFalse($published['isError']);
$this->assertSame('active', $published['data']['state']);
Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/listings/99'
&& $request->method() === 'PATCH'
&& $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();
$this->connectShop();
$result = $this->mcpCall('etsy_update_listing', ['listing_id' => 99, 'state' => 'active', 'title' => 'Nope']);
$this->assertTrue($result['isError']);
$this->assertStringContainsString('etsy_publish_listing', is_string($result['data']) ? $result['data'] : json_encode($result['data']));
Http::assertNothingSent();
}
public function test_list_listings_and_receipts_use_filters_not_dashboard_defaults(): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.etsy.com/v3/application/shops/42/listings*' => Http::response([
'count' => 1,
'results' => [['listing_id' => 7, 'title' => 'Draft hat', 'state' => 'draft', 'quantity' => 1, 'shop_id' => 42]],
]),
'https://api.etsy.com/v3/application/shops/42/receipts*' => Http::response([
'count' => 1,
'results' => [['receipt_id' => 15, 'status' => 'Paid', 'was_paid' => true, 'was_shipped' => true, 'name' => 'Buyer', 'grandtotal' => ['amount' => 500, 'divisor' => 100, 'currency_code' => 'GBP']]],
]),
]);
$this->connectShop();
$listings = $this->mcpCall('etsy_list_listings', ['state' => 'draft', 'limit' => 10, 'offset' => 0]);
$receipts = $this->mcpCall('etsy_list_receipts', ['was_paid' => true, 'was_shipped' => true, 'limit' => 25]);
$this->assertSame(7, $listings['data']['results'][0]['listing_id']);
$this->assertSame(15, $receipts['data']['results'][0]['receipt_id']);
Http::assertSent(fn ($request) => str_contains($request->url(), '/listings') && $request['state'] === 'draft' && (int) $request['limit'] === 10);
Http::assertSent(fn ($request) => str_contains($request->url(), '/receipts') && $request['was_paid'] === 'true' && $request['was_shipped'] === 'true');
}
public function test_mark_receipt_shipped_posts_tracking(): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.etsy.com/v3/application/shops/42/receipts/15/tracking' => Http::response([
'receipt_id' => 15, 'tracking_code' => 'ABC123', 'carrier_name' => 'evri', 'shop_id' => 42,
]),
]);
$this->connectShop();
$result = $this->mcpCall('etsy_mark_receipt_shipped', [
'receipt_id' => 15,
'tracking_code' => 'ABC123',
'carrier_name' => 'evri',
]);
$this->assertFalse($result['isError']);
Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/shops/42/receipts/15/tracking'
&& $request->method() === 'POST'
&& $request['tracking_code'] === 'ABC123'
&& $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]]]]])
: Http::response(['products' => [['product_id' => 1, 'sku' => 'HAT', 'property_values' => [], 'offerings' => [['offering_id' => 2, 'quantity' => 1, 'is_enabled' => true, 'price' => ['amount' => 1250, 'divisor' => 100, 'currency_code' => 'GBP']]]]]]);
},
'https://api.etsy.com/v3/application/shops/42/receipts/15' => Http::response([
'receipt_id' => 15,
'message_from_buyer' => 'Please leave in porch',
'message_from_seller' => null,
'name' => 'Buyer',
'email' => 'buyer@example.test',
]),
'https://api.etsy.com/v3/application/shops/42/receipts?*' => Http::response([
'count' => 1,
'results' => [[
'receipt_id' => 15,
'message_from_buyer' => 'Please leave in porch',
'message_from_seller' => '',
'name' => 'Buyer',
]],
]),
]);
$this->connectShop();
$inventory = $this->mcpCall('etsy_update_listing_inventory', ['listing_id' => 99, 'quantity' => 4]);
$this->assertFalse($inventory['isError']);
Http::assertSent(fn ($request) => $request->url() === 'https://api.etsy.com/v3/application/listings/99/inventory' && $request->method() === 'PUT');
$messages = $this->mcpCall('etsy_get_receipt_messages', ['receipt_id' => 15]);
$this->assertFalse($messages['isError']);
$this->assertSame('Please leave in porch', $messages['data']['message_from_buyer']);
$this->assertArrayNotHasKey('email', $messages['data']);
$this->assertFalse($messages['data']['conversations_api']['available']);
$listed = $this->mcpCall('etsy_list_receipt_messages');
$this->assertSame(15, $listed['data']['results'][0]['receipt_id']);
}
public function test_mutations_never_echo_tokens_even_when_etsy_error_bodies_contain_them(): void
{
Http::preventStrayRequests();
Http::fake([
'https://api.etsy.com/v3/application/shops/42/listings' => Http::response([
'error' => 'invalid token access_token=super-secret-token refresh_token=super-secret-refresh',
], 403),
]);
$this->connectShop();
$result = $this->mcpCall('etsy_create_draft_listing', [
'title' => 'Widget',
'description' => 'A printed widget',
'price' => 12.5,
'quantity' => 3,
'who_made' => 'i_did',
'when_made' => 'made_to_order',
'taxonomy_id' => 1234,
]);
$this->assertTrue($result['isError']);
$encoded = json_encode($result['rpc']);
$this->assertStringNotContainsString('super-secret-token', $encoded);
$this->assertStringNotContainsString('123.token', $encoded);
$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();
config()->set('services.etsy', ['keystring' => 'key', 'shared_secret' => 'secret', 'shop_name' => 'HickLab3D']);
EtsyConnection::create([
'shop_id' => 99, 'shop_name' => 'SomeoneElse',
'access_token' => '123.token', 'refresh_token' => '123.refresh',
'expires_at' => now()->addHour(),
'scopes' => 'listings_r listings_w transactions_r transactions_w shops_r',
]);
$result = $this->mcpCall('etsy_list_listings', ['state' => 'active']);
$this->assertTrue($result['isError']);
$this->assertStringContainsString('HickLab3D', is_string($result['data']) ? $result['data'] : json_encode($result['data']));
Http::assertNothingSent();
}
/**
* @param array<string, mixed> $message
* @return array<string, mixed>
*/
private function mcp(array $message): array
{
$raw = $this->mcpRaw($message);
$this->assertNotNull($raw);
$decoded = json_decode($raw, true);
$this->assertIsArray($decoded);
return $decoded;
}
/**
* @param array<string, mixed> $message
*/
private function mcpRaw(array $message): ?string
{
$command = $this->app->make(EtsyMcp::class);
$command->setLaravel($this->app);
$stdin = fopen('php://temp', 'w+');
fwrite($stdin, json_encode($message)."\n");
rewind($stdin);
$input = new ArrayInput([]);
$input->bind($command->getDefinition());
$input->setStream($stdin);
$output = new BufferedOutput(BufferedOutput::VERBOSITY_NORMAL, false);
$this->assertSame(0, $command->run($input, $output));
$raw = trim($output->fetch());
return $raw === '' ? null : $raw;
}
/**
* @param array<string, mixed> $arguments
* @return array{rpc: array<string, mixed>, data: mixed, isError: bool}
*/
private function mcpCall(string $name, array $arguments = [], int $id = 2): array
{
$response = $this->mcp([
'jsonrpc' => '2.0',
'id' => $id,
'method' => 'tools/call',
'params' => ['name' => $name, 'arguments' => $arguments],
]);
$this->assertArrayHasKey('result', $response);
$text = $response['result']['content'][0]['text'] ?? '';
$decoded = json_decode($text, true);
return [
'rpc' => $response,
'data' => is_array($decoded) ? $decoded : $text,
'isError' => (bool) ($response['result']['isError'] ?? false),
];
}
private function connectShop(string $scopes = 'listings_r listings_w transactions_r transactions_w shops_r'): void
{
config()->set('services.etsy', [
'keystring' => 'key',
'shared_secret' => 'secret',
'shop_name' => 'HickLab3D',
'redirect_uri' => 'https://example.test/etsy/callback',
]);
EtsyConnection::create([
'shop_id' => 42,
'shop_name' => 'HickLab3D',
'access_token' => '123.token',
'refresh_token' => '123.refresh',
'expires_at' => now()->addHour(),
'scopes' => $scopes,
]);
}
}