From c8c58221666ee3c71ec8c2feebbad2841bc07139 Mon Sep 17 00:00:00 2001 From: "Odin (Hermes)" Date: Sat, 7 Nov 2026 21:54:33 +0000 Subject: [PATCH] [verified] Harden Etsy MCP validation and scheduled token refresh --- .gitea/workflows/deploy.yml | 36 +---- README.md | 16 +- app/Console/Commands/EtsySync.php | 5 +- .../Commands/RefreshIntegrationCache.php | 6 - .../ScheduleSettingsController.php | 2 +- app/Services/Etsy.php | 143 +++++++++++++---- app/Services/EtsyMcpServer.php | 64 +++++++- app/Services/IntegrationCache.php | 2 +- app/Support/DashboardSchedule.php | 27 +++- tests/Feature/EtsyMcpTest.php | 151 ++++++++++++++++++ tests/Feature/EtsySyncScheduledJobsTest.php | 50 ++++++ tests/Feature/ScheduleSettingsTest.php | 23 +++ tests/Feature/SchedulerTest.php | 19 ++- 13 files changed, 455 insertions(+), 89 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e549503..b6272c7 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -166,41 +166,11 @@ jobs: exit 1 fi - - name: Ensure writable runtime paths + - name: Preserve managed production environment working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} - env: - RUNTIME_PATH: /tmp/${{ env.PROJECT_NAME }} run: | - mkdir -p "$RUNTIME_PATH/views" "$RUNTIME_PATH/logs" "$RUNTIME_PATH/cache" - chmod 0777 "$RUNTIME_PATH" "$RUNTIME_PATH/views" "$RUNTIME_PATH/logs" "$RUNTIME_PATH/cache" - - if grep -q '^VIEW_COMPILED_PATH=' .env; then - sed -i "s|^VIEW_COMPILED_PATH=.*|VIEW_COMPILED_PATH=/tmp/HickLab3D.views|" .env - else - echo "VIEW_COMPILED_PATH=/tmp/HickLab3D.views" >> .env - fi - - if grep -q '^LOG_PATH=' .env; then - sed -i "s|^LOG_PATH=.*|LOG_PATH=/tmp/HickLab3D.laravel.log|" .env - else - echo "LOG_PATH=/tmp/HickLab3D.laravel.log" >> .env - fi - - for key_path in \ - "APP_SERVICES_CACHE=storage/framework/cache/data/services.php" \ - "APP_PACKAGES_CACHE=storage/framework/cache/data/packages.php" \ - "APP_CONFIG_CACHE=/tmp/HickLab3D.config.php" \ - "APP_ROUTES_CACHE=/tmp/HickLab3D.routes.php" \ - "APP_EVENTS_CACHE=/tmp/HickLab3D.events.php" - do - key="${key_path%%=*}" - value="${key_path#*=}" - if grep -q "^${key}=" .env; then - sed -i "s|^${key}=.*|${key}=${value}|" .env - else - echo "${key}=${value}" >> .env - fi - done + # Infisical owns .env. Never rewrite runtime paths or integration settings here. + test -f .env - name: Install production PHP dependencies working-directory: ${{ env.DEPLOY_ROOT }}/${{ env.PROJECT_NAME }} diff --git a/README.md b/README.md index 66adf4d..ff6c68f 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,23 @@ First-party stdio MCP for HL3D agents. It uses the stored HickLab3D shop connect php artisan etsy:mcp ``` -Register it from this repo's `.mcp.json` as server name `etsy` (`php` + `artisan etsy:mcp`). Example for an HL3D Hermes profile after this server exists: +Register the local `.mcp.json` only where PHP is installed and the working directory is this app. On the Hermes host, use the existing web container instead (after deployment): ```sh -hermes -p hl3d_teamlead mcp add etsy --command php --args artisan etsy:mcp +hermes -p hl3d_teamlead mcp add etsy --command ssh --args -T H-NODE-05 'docker exec -i -u www-data -w /var/www/HickLab3D nginx-ui-php php artisan etsy:mcp --no-ansi' ``` -Run the command from the dashboard app root so it inherits `.env`. After deploy, an admin must reconnect Etsy in the dashboard so the token includes `listings_r listings_w transactions_r transactions_w shops_r`. New listings are created as drafts; publishing requires `etsy_publish_listing` with `confirm=true`. +This uses the managed live environment without placing credentials in MCP config and does not require host PHP. Configure positive tool allow-lists through supported `hermes config set` commands before exposing tools to agents: teamlead/designer should get read/status tools only; marketing may additionally create drafts. Do not expose publishing, shipment or inventory-write tools without separately authorising those operations. Disable MCP sampling for this server. Start a fresh profile session or reload MCP after configuration. + +An admin must reconnect Etsy in the dashboard if status reports unknown or missing scopes (`listings_r listings_w transactions_r transactions_w shops_r`). Never request operator passwords or MFA codes. New listings are drafts; `confirm=true` is a technical publish guard, not operator authorisation. Publishing, activating, renewing or repricing live listings, inventory changes and shipping still require explicit approval for the specific action in the current conversation. + +## Artisan scheduler + +The Settings page lets admins tune registered jobs or run them immediately. Defaults: `integrations:refresh` and `alerts:scan` every 30 seconds, `etsy:sync` every 15 minutes, and `etsy:refresh-token` checks every five minutes. Etsy is no longer force-refreshed by the bundled integration job. The refresh check rotates only within ten minutes of expiry; database row locking and a fresh read serialise rotating-token use across MCP, HTTP and scheduler processes. Lowering the token-check cadence or disabling it can leave an idle access token expired. + +Settings are atomically replaced under a shared file lock at `storage/app/dashboard-schedules.json`. Preserve storage and the Infisical-managed `.env` during deployments. Only H-NODE-05 owns the systemd scheduler timer; its container working directory must be `/var/www/HickLab3D`, matching the actual vhost and CI target. Subminute tasks keep `schedule:run` alive until the end of the minute, so do not install a duplicate cron or scheduler elsewhere. + +For local tests in the existing serviceengineering toolchain image, explicitly set `APP_ENV=testing`: the image exports `APP_ENV=local`, which otherwise wins over PHPUnit XML and correctly activates real CSRF enforcement. Never disable production CSRF to repair that test-environment mismatch. Etsy Open API v3 has no conversation inbox (`conversations_*` scopes do not exist). Receipt notes are exposed; sending a buyer-inbox reply is not. diff --git a/app/Console/Commands/EtsySync.php b/app/Console/Commands/EtsySync.php index baea98c..dbe62f6 100644 --- a/app/Console/Commands/EtsySync.php +++ b/app/Console/Commands/EtsySync.php @@ -23,10 +23,12 @@ class EtsySync extends Command return self::SUCCESS; } + $failed = false; try { $receipts = $etsy->receipts($connection, fresh: true); $this->line('etsy receipts: refreshed ('.($receipts['count'] ?? 0).' open)'); } catch (Throwable $exception) { + $failed = true; $this->warn('etsy receipts: '.$exception->getMessage()); } @@ -34,9 +36,10 @@ class EtsySync extends Command $listings = $etsy->listings($connection, fresh: true); $this->line('etsy listings: refreshed ('.count($listings['results'] ?? []).' active)'); } catch (Throwable $exception) { + $failed = true; $this->warn('etsy listings: '.$exception->getMessage()); } - return self::SUCCESS; + return $failed ? self::FAILURE : self::SUCCESS; } } diff --git a/app/Console/Commands/RefreshIntegrationCache.php b/app/Console/Commands/RefreshIntegrationCache.php index a321d9d..3bc3996 100644 --- a/app/Console/Commands/RefreshIntegrationCache.php +++ b/app/Console/Commands/RefreshIntegrationCache.php @@ -2,7 +2,6 @@ namespace App\Console\Commands; -use App\Models\EtsyConnection; use App\Services\Bambuddy; use App\Services\DryerClient; use App\Services\Etsy; @@ -22,11 +21,6 @@ class RefreshIntegrationCache extends Command { $cache->forceRefresh = true; - if (($connection = EtsyConnection::first()) && $etsy->configured()) { - $this->refresh('etsy receipts', fn () => $etsy->receipts($connection)); - $this->refresh('etsy listings', fn () => $etsy->listings($connection)); - } - $this->refresh('parcel2go', function () use ($parcel2Go): void { $parcel2Go->verifyCredentials(); foreach (['me/orders/detail', 'me/detail', 'me/prepay/balance'] as $path) { diff --git a/app/Http/Controllers/ScheduleSettingsController.php b/app/Http/Controllers/ScheduleSettingsController.php index 5d3a8c0..dff4c8c 100644 --- a/app/Http/Controllers/ScheduleSettingsController.php +++ b/app/Http/Controllers/ScheduleSettingsController.php @@ -21,7 +21,7 @@ class ScheduleSettingsController extends Controller $frequencyKeys = array_keys(DashboardSchedule::frequencyOptions()); $validated = $request->validate([ - 'schedules' => ['required', 'array'], + 'schedules' => ['required', 'array:'.implode(',', $jobKeys)], 'schedules.*' => ['required', 'string', Rule::in($frequencyKeys)], ]); diff --git a/app/Services/Etsy.php b/app/Services/Etsy.php index fd1a658..c01caf6 100644 --- a/app/Services/Etsy.php +++ b/app/Services/Etsy.php @@ -6,6 +6,8 @@ use App\Models\EtsyAppCredential; use App\Models\EtsyConnection; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; use RuntimeException; class Etsy @@ -217,18 +219,55 @@ class Etsy return $this->summarizeListing($listing, detailed: true); } - /** - * @param array $payload + private function requireScope(EtsyConnection $connection, string $scope): void + { + if (! in_array($scope, $this->scopeList($connection->scopes) ?? [], true)) { + throw new RuntimeException('An admin must reconnect Etsy in the dashboard to grant '.$scope.'.'); + } + } + + /** @param array $payload * @return array */ + private function validatedListingPayload(array $payload, bool $creating): array + { + unset($payload['listing_id']); + if ($creating) { + unset($payload['shop_id'], $payload['state']); + } + $allowedFields = $creating + ? 'title,description,price,quantity,who_made,when_made,taxonomy_id,shipping_profile_id,tags,materials,type' + : 'title,description,taxonomy_id,tags,state'; + $required = $creating ? 'required' : 'sometimes'; + $rules = [ + 'listing' => ['required', 'array:'.$allowedFields], + 'listing.title' => [$required, 'string', 'max:140'], + 'listing.description' => [$required, 'string'], + 'listing.price' => [$required, 'numeric', 'gt:0'], + 'listing.quantity' => [$required, 'integer', 'min:1'], + 'listing.who_made' => [$required, Rule::in(['i_did', 'someone_else', 'collective'])], + 'listing.when_made' => [$required, 'string', 'max:32'], + 'listing.taxonomy_id' => [$required, 'integer', 'min:1'], + 'listing.shipping_profile_id' => ['sometimes', 'integer', 'min:1'], + 'listing.tags' => ['sometimes', 'array', 'max:13'], + 'listing.tags.*' => ['string', 'max:20'], + 'listing.materials' => ['sometimes', 'array'], + 'listing.materials.*' => ['string'], + 'listing.type' => ['sometimes', Rule::in(['physical', 'download', 'both'])], + 'listing.state' => ['sometimes', Rule::in(['draft', 'inactive'])], + ]; + $validator = Validator::make(['listing' => $payload], $rules); + if ($validator->fails()) { + throw new RuntimeException('Invalid listing payload: '.$validator->errors()->first()); + } + + return $validator->validated()['listing']; + } + public function createDraftListing(EtsyConnection $connection, array $payload): array { - foreach (['quantity', 'title', 'description', 'price', 'who_made', 'when_made', 'taxonomy_id'] as $field) { - if (! array_key_exists($field, $payload) || $payload[$field] === '' || $payload[$field] === null) { - throw new RuntimeException("Draft listing requires {$field}."); - } - } - unset($payload['state'], $payload['shop_id'], $payload['listing_id']); + $this->requireScope($connection, 'listings_w'); + $payload = $this->validatedListingPayload($payload, true); $listing = $this->request('POST', 'application/shops/'.$connection->shop_id.'/listings', $this->accessToken($connection), body: $payload); $this->cache->forget('etsy.listings.'.$connection->shop_id); @@ -244,7 +283,9 @@ class Etsy if (isset($payload['state']) && strtolower((string) $payload['state']) === 'active') { throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true. HL3D listings stay draft until the operator authorises going live.'); } - unset($payload['shop_id'], $payload['listing_id']); + $this->requireScope($connection, 'listings_w'); + $payload = $this->validatedListingPayload($payload, false); + $this->getListing($connection, $listingId); $listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: $payload); $this->assertListingBelongsToShop($connection, $listing); $this->cache->forget('etsy.listings.'.$connection->shop_id); @@ -257,9 +298,11 @@ class Etsy */ public function publishListing(EtsyConnection $connection, int $listingId, bool $confirm = false): array { + $this->requireScope($connection, 'listings_w'); if (! $confirm) { throw new RuntimeException('Publishing a listing requires confirm=true. HL3D listings stay draft until the operator authorises going live.'); } + $this->getListing($connection, $listingId); $listing = $this->request('PATCH', 'application/shops/'.$connection->shop_id.'/listings/'.$listingId, $this->accessToken($connection), body: ['state' => 'active']); $this->assertListingBelongsToShop($connection, $listing); $this->cache->forget('etsy.listings.'.$connection->shop_id); @@ -272,6 +315,8 @@ class Etsy */ public function getListingInventory(EtsyConnection $connection, int $listingId): array { + $this->getListing($connection, $listingId); + return $this->request('GET', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection)); } @@ -281,13 +326,38 @@ class Etsy */ public function updateListingInventory(EtsyConnection $connection, int $listingId, array $payload): array { + $this->requireScope($connection, 'listings_w'); + $validator = Validator::make($payload, [ + 'quantity' => ['required_without:products', 'integer', 'min:0'], + 'products' => ['required_without:quantity', 'array', 'min:1'], + 'products.*' => ['array:sku,property_values,offerings'], + 'products.*.sku' => ['sometimes', 'string'], + 'products.*.property_values' => ['sometimes', 'array'], + 'products.*.offerings' => ['required', 'array', 'min:1'], + 'products.*.offerings.*' => ['array:price,quantity,is_enabled,readiness_state_id'], + 'products.*.offerings.*.price' => ['required', 'numeric', 'gt:0'], + 'products.*.offerings.*.quantity' => ['required', 'integer', 'min:0'], + 'products.*.offerings.*.is_enabled' => ['required', 'boolean'], + 'products.*.offerings.*.readiness_state_id' => ['sometimes', 'integer', 'min:1'], + ]); + if ($validator->fails()) { + throw new RuntimeException('Invalid inventory payload: '.$validator->errors()->first()); + } + $this->getListing($connection, $listingId); + $currentInventory = $this->getListingInventory($connection, $listingId); if (! isset($payload['products']) && array_key_exists('quantity', $payload)) { - $payload = ['products' => $this->productsWithQuantity($this->getListingInventory($connection, $listingId), (int) $payload['quantity'])]; + $payload = ['products' => $this->productsWithQuantity($currentInventory, (int) $payload['quantity'])]; } if (! isset($payload['products']) || ! is_array($payload['products'])) { throw new RuntimeException('Inventory update requires products or quantity.'); } - $inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: ['products' => $payload['products']], jsonBody: true); + $body = ['products' => $payload['products']]; + foreach (['price_on_property', 'quantity_on_property', 'sku_on_property', 'readiness_state_on_property'] as $field) { + if (array_key_exists($field, $currentInventory)) { + $body[$field] = $currentInventory[$field]; + } + } + $inventory = $this->request('PUT', 'application/listings/'.$listingId.'/inventory', $this->accessToken($connection), body: $body, jsonBody: true); $this->cache->forget('etsy.listings.'.$connection->shop_id); return $inventory; @@ -332,6 +402,7 @@ class Etsy */ public function createReceiptShipment(EtsyConnection $connection, int $receiptId, string $trackingCode, string $carrierName, array $extra = []): array { + $this->requireScope($connection, 'transactions_w'); if ($trackingCode === '' || $carrierName === '') { throw new RuntimeException('Marking a receipt shipped requires tracking_code and carrier_name.'); } @@ -400,29 +471,38 @@ class Etsy */ public function refreshAccessToken(EtsyConnection $connection): string { - return $this->accessToken($connection); + return $this->accessToken($connection, 600); } - private function accessToken(EtsyConnection $connection): string + private function accessToken(EtsyConnection $connection, int $marginSeconds = 0): string { - if ($connection->expires_at->isFuture()) { - return $connection->access_token; - } - $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) - ->post(self::API_URL.'/public/oauth/token', [ - 'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'], - 'refresh_token' => $connection->refresh_token, - ])->throw()->json(); - if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) { - throw new RuntimeException('Etsy did not refresh the shop connection.'); - } - $connection->update([ - 'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'], - 'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)), - ...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [], - ]); + return $connection->getConnection()->transaction(function () use ($connection, $marginSeconds): string { + // Lock the shared database row, not a per-process cache: HTTP, MCP + // and scheduler workers must re-read the latest rotating token. + $current = EtsyConnection::query()->whereKey($connection->getKey())->lockForUpdate()->firstOrFail(); + if ($current->expires_at->greaterThan(now()->addSeconds($marginSeconds))) { + $connection->setRawAttributes($current->getAttributes(), true); - return $tokens['access_token']; + return $current->access_token; + } + + $tokens = Http::asForm()->acceptJson()->connectTimeout(3)->timeout(10) + ->post(self::API_URL.'/public/oauth/token', [ + 'grant_type' => 'refresh_token', 'client_id' => $this->credentials()['keystring'], + 'refresh_token' => $current->refresh_token, + ])->throw()->json(); + if (empty($tokens['access_token']) || empty($tokens['refresh_token'])) { + throw new RuntimeException('Etsy did not refresh the shop connection.'); + } + $current->update([ + 'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'], + 'expires_at' => now()->addSeconds(max(60, (int) ($tokens['expires_in'] ?? 3600) - 60)), + ...isset($tokens['scope']) ? ['scopes' => trim((string) $tokens['scope'])] : [], + ]); + $connection->setRawAttributes($current->getAttributes(), true); + + return $current->access_token; + }); } /** @@ -471,7 +551,7 @@ class Etsy */ private function assertListingBelongsToShop(EtsyConnection $connection, array $listing): void { - if (isset($listing['shop_id']) && (int) $listing['shop_id'] !== (int) $connection->shop_id) { + if (! isset($listing['shop_id']) || (int) $listing['shop_id'] !== (int) $connection->shop_id) { throw new RuntimeException('That listing does not belong to the connected HickLab3D shop.'); } } @@ -563,6 +643,7 @@ class Etsy 'price' => $price, 'quantity' => $quantity, 'is_enabled' => $offering['is_enabled'] ?? true, + ...array_intersect_key($offering, array_flip(['readiness_state_id'])), ]; } $products[] = [ diff --git a/app/Services/EtsyMcpServer.php b/app/Services/EtsyMcpServer.php index 2100115..f0c7686 100644 --- a/app/Services/EtsyMcpServer.php +++ b/app/Services/EtsyMcpServer.php @@ -61,7 +61,7 @@ class EtsyMcpServer 'protocolVersion' => is_string($params['protocolVersion'] ?? null) ? $params['protocolVersion'] : '2024-11-05', 'capabilities' => ['tools' => ['listChanged' => false]], 'serverInfo' => ['name' => 'etsy', 'version' => '1.0.0'], - 'instructions' => 'HickLab3D Etsy shop MCP. Uses the dashboard stored shop connection — do not start a new OAuth dance. New listings are drafts unless etsy_publish_listing is called with confirm=true. After deploy, an admin must reconnect Etsy in the dashboard so the token includes listings_w, transactions_w, and shops_r. Buyer-inbox send is not available on Etsy Open API v3.', + 'instructions' => 'HickLab3D Etsy shop MCP. Uses the dashboard stored shop connection — do not start a new OAuth dance. New listings are drafts. Never publish, activate, renew, reprice a live listing, change inventory or shop settings, or ship orders without explicit operator authorisation for that specific action in the current conversation. confirm=true is only a technical guard, not authorisation. After deploy, an admin must reconnect Etsy in the dashboard so the token includes listings_w, transactions_w, and shops_r. Buyer-inbox send is not available on Etsy Open API v3.', ], 'notifications/initialized', 'initialized' => [], 'ping' => ['ok' => true], @@ -78,6 +78,18 @@ class EtsyMcpServer private function callTool(string $name, array $arguments): array { try { + $validationArguments = $arguments; + if ($name === 'etsy_create_draft_listing') { + unset($validationArguments['state']); + } + if ($name === 'etsy_update_listing' && ($arguments['state'] ?? null) === 'active') { + throw new RuntimeException('Activating a listing requires etsy_publish_listing with confirm=true.'); + } + $tool = collect($this->tools())->firstWhere('name', $name); + if ($tool === null) { + throw new RuntimeException('Unknown tool: '.$name); + } + $this->validateSchema($validationArguments, $tool['inputSchema'], 'arguments'); $data = $this->invoke($name, $arguments); $payload = $this->redact($data); @@ -182,7 +194,7 @@ class EtsyMcpServer 'type' => ['type' => 'string', 'enum' => ['physical', 'download', 'both']], ], 'required' => ['title', 'description', 'price', 'quantity', 'who_made', 'when_made', 'taxonomy_id'], - 'additionalProperties' => true, + 'additionalProperties' => false, ]), $this->tool('etsy_update_listing', 'Update HickLab3D listing fields (title, description, tags, state=inactive, etc). Refuses state=active — use etsy_publish_listing with confirm=true to go live.', [ 'type' => 'object', @@ -195,7 +207,7 @@ class EtsyMcpServer 'state' => ['type' => 'string', 'enum' => ['inactive', 'draft'], 'description' => 'inactive deactivates. active is rejected; use etsy_publish_listing.'], ], 'required' => ['listing_id'], - 'additionalProperties' => true, + 'additionalProperties' => false, ]), $this->tool('etsy_publish_listing', 'Publish a draft HickLab3D listing (state=active). Requires confirm=true. The listing must already have images (and a shipping profile for physical items).', [ 'type' => 'object', @@ -287,11 +299,51 @@ class EtsyMcpServer */ private function requiredInt(array $arguments, string $key): int { - if (! isset($arguments[$key]) || ! is_numeric($arguments[$key])) { - throw new RuntimeException("{$key} is required."); + if (! isset($arguments[$key]) || ! is_int($arguments[$key]) || $arguments[$key] < 1) { + throw new RuntimeException("{$key} must be a positive integer."); } - return (int) $arguments[$key]; + return $arguments[$key]; + } + + /** @param array $schema */ + private function validateSchema(mixed $value, array $schema, string $path): void + { + $validType = match ($schema['type'] ?? null) { + 'object' => is_array($value) && ($value === [] || ! array_is_list($value)), + 'array' => is_array($value) && array_is_list($value), + 'integer' => is_int($value), + 'number' => is_int($value) || is_float($value), + 'boolean' => is_bool($value), + 'string' => is_string($value), + default => false, + }; + if (! $validType + || (isset($schema['enum']) && ! in_array($value, $schema['enum'], true)) + || (isset($schema['minimum']) && $value < $schema['minimum']) + || (isset($schema['maximum']) && $value > $schema['maximum'])) { + throw new RuntimeException('Invalid listing payload: '.$path.' does not match the tool schema.'); + } + if (($schema['type'] ?? null) === 'object') { + $properties = (array) ($schema['properties'] ?? []); + foreach ($schema['required'] ?? [] as $key) { + if (! array_key_exists($key, $value)) { + throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is required.'); + } + } + foreach ($value as $key => $item) { + if (isset($properties[$key])) { + $this->validateSchema($item, $properties[$key], $path.'.'.$key); + } elseif (($schema['additionalProperties'] ?? true) === false) { + throw new RuntimeException('Invalid listing payload: '.$path.'.'.$key.' is not supported.'); + } + } + } + if (($schema['type'] ?? null) === 'array' && isset($schema['items'])) { + foreach ($value as $key => $item) { + $this->validateSchema($item, $schema['items'], $path.'.'.$key); + } + } } /** diff --git a/app/Services/IntegrationCache.php b/app/Services/IntegrationCache.php index 4b6f200..dbe24ea 100644 --- a/app/Services/IntegrationCache.php +++ b/app/Services/IntegrationCache.php @@ -44,7 +44,7 @@ class IntegrationCache try { $value = $fetch(); } catch (Throwable $exception) { - if (is_array($entry)) { + if (is_array($entry) && ! $fresh) { report($exception); return $entry['value']; diff --git a/app/Support/DashboardSchedule.php b/app/Support/DashboardSchedule.php index a45cc4f..f44ec4f 100644 --- a/app/Support/DashboardSchedule.php +++ b/app/Support/DashboardSchedule.php @@ -26,7 +26,7 @@ class DashboardSchedule 'name' => 'Integration cache refresh', 'feature' => 'Integrations', 'command' => 'integrations:refresh', - 'description' => 'Refreshes cached Etsy, Parcel2Go, BamBuddy and dryer data so dashboard pages load instantly. Migrated from the previous hard-coded schedule entry — frequency and withoutOverlapping(5) behavior unchanged.', + 'description' => 'Refreshes cached Parcel2Go, BamBuddy and dryer data. Etsy runs separately through etsy:sync; the 30-second cadence and withoutOverlapping(5) guard are unchanged.', 'default_frequency' => 'every_30_seconds', 'overlap_minutes' => 5, 'manual' => true, @@ -46,16 +46,16 @@ class DashboardSchedule 'command' => 'etsy:sync', 'description' => 'Forces a fresh pull of Etsy receipts and active listings into the same IntegrationCache entries the Etsy page reads, independent of the bundled integrations:refresh job so it can be tuned or disabled on its own.', 'default_frequency' => 'every_15_minutes', - 'overlap_minutes' => null, + 'overlap_minutes' => 5, 'manual' => true, ], 'etsy_token_refresh' => [ 'name' => 'Etsy token refresh', 'feature' => 'Etsy', 'command' => 'etsy:refresh-token', - 'description' => 'Proactively refreshes the Etsy OAuth access token (tokens last ~1 hour) so the connection stays alive even during quiet periods with no receipts or listings activity to trigger an on-demand refresh.', - 'default_frequency' => 'every_45_minutes', - 'overlap_minutes' => null, + 'description' => 'Checks every five minutes by default and refreshes only when expiry is within ten minutes. Disabling or slowing this job can leave idle tokens expired; on-demand reads also refresh expired tokens.', + 'default_frequency' => 'every_5_minutes', + 'overlap_minutes' => 5, 'manual' => true, ], ]; @@ -104,7 +104,22 @@ class DashboardSchedule ->all(); File::ensureDirectoryExists(dirname(self::runtimeSettingsPath())); - File::put(self::runtimeSettingsPath(), json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + $lock = fopen(self::runtimeSettingsPath().'.lock', 'c'); + if ($lock === false) { + throw new \RuntimeException('Unable to open schedule settings lock.'); + } + try { + if (! flock($lock, LOCK_EX)) { + throw new \RuntimeException('Unable to lock schedule settings.'); + } + $stored = File::exists(self::runtimeSettingsPath()) + ? json_decode(File::get(self::runtimeSettingsPath()), true, 512, JSON_THROW_ON_ERROR) + : []; + File::replace(self::runtimeSettingsPath(), json_encode(array_replace(is_array($stored) ? $stored : [], $payload), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } } public static function register(): void diff --git a/tests/Feature/EtsyMcpTest.php b/tests/Feature/EtsyMcpTest.php index 8829985..49c8edb 100644 --- a/tests/Feature/EtsyMcpTest.php +++ b/tests/Feature/EtsyMcpTest.php @@ -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(); diff --git a/tests/Feature/EtsySyncScheduledJobsTest.php b/tests/Feature/EtsySyncScheduledJobsTest.php index d0ed391..6e54a0a 100644 --- a/tests/Feature/EtsySyncScheduledJobsTest.php +++ b/tests/Feature/EtsySyncScheduledJobsTest.php @@ -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([ diff --git a/tests/Feature/ScheduleSettingsTest.php b/tests/Feature/ScheduleSettingsTest.php index f40b2a5..2538105 100644 --- a/tests/Feature/ScheduleSettingsTest.php +++ b/tests/Feature/ScheduleSettingsTest.php @@ -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 diff --git a/tests/Feature/SchedulerTest.php b/tests/Feature/SchedulerTest.php index 466bc24..0727d80 100644 --- a/tests/Feature/SchedulerTest.php +++ b/tests/Feature/SchedulerTest.php @@ -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