[verified] Harden Etsy MCP validation and scheduled token refresh
Deploy HickLab3D Dashboard / Deploy to production (push) Successful in 38s

This commit is contained in:
2026-11-07 21:54:33 +00:00
parent 960189d14b
commit c8c5822166
13 changed files with 455 additions and 89 deletions
+151
View File
@@ -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([
+23
View File
@@ -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
+18 -1
View File
@@ -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