diff --git a/composer.json b/composer.json index 267085d0..f386e3db 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "^8.0", - "fleetbase/core-api": "*", + "fleetbase/core-api": "^1.6.69", "fleetbase/fleetops-api": "*", "geocoder-php/google-maps-places-provider": "^1.4", "laravel-notification-channels/apn": "^5.0", diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index df5aeddb..1aa9443c 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -32,6 +32,7 @@ use Fleetbase\Storefront\Promotions\PromotionUnavailableException; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Storefront\Support\StripeUtils; use Fleetbase\Support\SocketCluster\SocketClusterService; use Illuminate\Http\JsonResponse; @@ -385,7 +386,7 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa // GET /checkouts/status needs BOTH, and only initializeQPayCheckout was returning // the id — so a cash or card client could never reach its own checkout's status. // The checkout is discarded instead if one of its promotions ran out meanwhile. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'checkout' => $checkout->public_id, 'token' => $checkout->token, ]); @@ -486,7 +487,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate // See initializeCheckout: `checkout` is the chkt_* public id GET /checkouts/status // requires alongside the token, and nothing but the QPay path used to return it. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -718,7 +719,7 @@ public function updateStripePaymentIntent(Request $request) // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is // the chkt_* public id GET /checkouts/status requires alongside the token. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -842,7 +843,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Update checkout with invoice id $checkout->updateOption('qpay_invoice_id', data_get($invoice, 'invoice_id')); - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'invoice' => $invoice, 'checkout' => $checkout->public_id, 'token' => $checkout->token, @@ -861,7 +862,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa * response for either success or error scenarios. * - Initializes a QPay instance with the gateway configuration and sets the authentication token. * - Retrieves the invoice ID from the checkout options and performs a payment check using QPay's API. - * - Publishes the payment data or error response to the SocketCluster channel. + * - Publishes the checkout status, its order and any error to the checkout's realtime channel. * * Depending on the 'respond' flag from the request, the method returns a JSON response * or completes the processing without returning data. @@ -925,7 +926,7 @@ public function captureQPayCallback(Request $request) ]; } - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, $testScenario === 'success', $data['error']); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -978,7 +979,7 @@ public function captureQPayCallback(Request $request) 'error' => null, ]; - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, true); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -2143,6 +2144,74 @@ protected static function promotionCodesFor(Cart $cart, Request $request): array return array_values(array_unique(array_filter(array_merge((array) $codes, $cart->getPromotionCodes()), 'is_string'))); } + /** + * The JSON response for an initialized checkout. + * + * When realtime socket authentication is enabled it carries `socket_token`: a + * `checkout` token whose scope is exactly this checkout's channel, so a client + * (a guest included) can listen for its own payment confirmation. The field is + * absent while socket authentication is disabled. + */ + protected static function checkoutResponse(Checkout $checkout, array $data): JsonResponse + { + $socketToken = StorefrontSocket::checkoutToken($checkout); + if ($socketToken) { + $data['socket_token'] = $socketToken; + } + + return response()->json($data); + } + + /** + * Publishes a checkout's progress on its realtime channel. + * + * The payload is what a storefront client acts on — the checkout, its status, the + * order once one exists (serialized exactly as GET checkouts/status returns it) and + * any error — never the raw gateway payment record. A publish failure is logged and + * swallowed: by now the payment is recorded, and clients still recover the outcome + * through GET checkouts/status. + * + * @return array|null the published payload, or null when publishing failed + */ + protected static function publishCheckoutUpdate(Checkout $checkout, bool $paid, ?array $error = null): ?array + { + try { + // A failed payment carries no order, so a client never completes on an error event. + $order = !$error && $checkout->order_uuid ? Order::where('uuid', $checkout->order_uuid)->first() : null; + $status = 'pending'; + if ($error) { + $status = 'failed'; + } elseif ($order) { + $status = 'completed'; + } elseif ($paid) { + $status = 'paid'; + } + + $data = [ + 'checkout' => $checkout->public_id, + 'status' => $status, + 'order' => $order ? static::checkoutChannelOrder($order) : null, + 'error' => $error, + ]; + + SocketClusterService::publish(StorefrontSocket::checkoutChannel($checkout), $data); + + return $data; + } catch (\Throwable $e) { + Log::warning('[CHECKOUT SOCKET PUBLISH FAILED]: ' . $e->getMessage(), ['checkout' => $checkout->public_id]); + + return null; + } + } + + /** + * Serializes a checkout's order for its realtime channel, as GET checkouts/status does. + */ + protected static function checkoutChannelOrder(Order $order): array + { + return json_decode(json_encode(new OrderResource($order)), true); + } + /** * Reserve a new checkout's promotions, discarding the checkout if one ran out meanwhile. */ diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 1aa827e4..8e47c447 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -22,6 +22,8 @@ use Fleetbase\Storefront\Http\Resources\Customer; use Fleetbase\Storefront\Push\StorefrontPushChannel; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -127,6 +129,36 @@ public function unregisterDevice(Request $request) ]); } + /** + * Mints a realtime socket token for the signed-in customer. + * + * POST storefront/v1/customers/socket-token — authenticated like every other + * customer endpoint: the storefront key plus a Customer-Token header. The token + * is a `customer` principal scoped to the store or network the key belongs to; + * the socket server only lets it subscribe to channels the customer owns. + * Returns 404 while socket authentication is not configured on this instance. + */ + public function socketToken(Request $request) + { + if (!SocketToken::enabled()) { + return response()->apiError('Not found.', 404); + } + + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + // A customer's token is only honoured by the storefront whose company the + // customer belongs to, so a token minted against another company's key is refused. + $storefront = Storefront::about(); + if (!$storefront || $storefront->company_uuid !== $customer->company_uuid) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + return response()->json(SocketToken::issue(StorefrontSocket::customerPrincipal($customer, $storefront))); + } + /** * Newer core-api versions add push metadata columns to user_devices. * diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 8fd668d9..79b25d48 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -4,6 +4,8 @@ use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider; use Fleetbase\Providers\CoreServiceProvider; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; // These dependency guards are only reachable before Composer can load this provider. // The test runtime necessarily has both parent providers loaded, so the throw paths cannot execute. @@ -111,5 +113,15 @@ public function boot() $this->mergeConfigFrom(__DIR__ . '/../../config/database.connections.php', 'database.connections'); $this->mergeConfigFrom(__DIR__ . '/../../config/storefront.php', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); + $this->registerStorefrontSocketChannels(); + } + + /** + * Registers the realtime channel prefixes storefront owns (`storefront`, `checkout`) + * with core-api's socket channel registry, so the socket server can authorize them. + */ + public function registerStorefrontSocketChannels(): void + { + StorefrontSocket::registerChannels($this->app->make(SocketChannelRegistry::class)); } } diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php new file mode 100644 index 00000000..f8d1b66a --- /dev/null +++ b/server/src/Support/StorefrontSocket.php @@ -0,0 +1,180 @@ + 'customer', + 'sub' => $customer->uuid, + 'cid' => $storefront->company_uuid, + 'cpid' => static::companyPublicId($storefront->company_uuid), + 'env' => static::ENV, + 'ids' => [$customer->uuid, $customer->public_id], + 'adm' => false, + 'scp' => null, + 'sid' => $storefront->uuid, + ]); + } + + /** + * Builds the `checkout` principal: it may only ever subscribe to its own checkout channel. + */ + public static function checkoutPrincipal(Checkout $checkout): SocketPrincipal + { + return SocketPrincipal::fromClaims([ + 'kind' => 'checkout', + 'sub' => $checkout->uuid, + 'cid' => $checkout->company_uuid, + 'cpid' => static::companyPublicId($checkout->company_uuid), + 'env' => static::ENV, + 'ids' => [$checkout->uuid, $checkout->public_id], + 'adm' => false, + 'scp' => [static::checkoutChannel($checkout)], + 'sid' => $checkout->store_uuid ?? $checkout->network_uuid, + ]); + } + + /** + * Mints the socket token returned with an initialized checkout, or null while socket auth is disabled. + */ + public static function checkoutToken(Checkout $checkout): ?array + { + if (!SocketToken::enabled()) { + return null; + } + + return SocketToken::issue(static::checkoutPrincipal($checkout)); + } + + /** + * The channel checkout progress is published on. + */ + public static function checkoutChannel(Checkout $checkout): string + { + return 'checkout.' . $checkout->public_id; + } + + /** + * Registers storefront's channel resolvers with core-api's socket channel registry. + * + * @param \Fleetbase\Support\SocketCluster\SocketChannelRegistry $registry + */ + public static function registerChannels($registry): void + { + $registry->register('storefront', \Closure::fromCallable([static::class, 'authorizeStorefront'])); + $registry->register('checkout', \Closure::fromCallable([static::class, 'authorizeCheckout'])); + } + + /** + * `storefront.{id}` — id is a store or network uuid, public id or key. + * + * Console users and API credentials may subscribe to their own company's + * storefronts; a customer only to the storefront their token was minted for. + */ + public static function authorizeStorefront(SocketPrincipal $principal, string $id, string $channel): bool + { + $storefront = static::findStorefront($id); + + if (!$storefront || !$storefront->company_uuid || $storefront->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $principal->sid !== null && $storefront->uuid === $principal->sid; + } + + return false; + } + + /** + * `checkout.{id}` — id is a checkout uuid or public id. + * + * Console users and API credentials may subscribe to their own company's + * checkouts; a customer only to checkouts they own. A `checkout` principal never + * reaches here: its `scp` already limits it to its own channel. + */ + public static function authorizeCheckout(SocketPrincipal $principal, string $id, string $channel): bool + { + $checkout = Checkout::select(['uuid', 'public_id', 'company_uuid', 'owner_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$checkout || !$checkout->company_uuid || $checkout->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $checkout->owner_uuid !== null && $checkout->owner_uuid === $principal->sub; + } + + return false; + } + + /** + * Finds a store, then a network, by uuid, public id or key. + */ + protected static function findStorefront(string $id): Store|Network|null + { + foreach ([Store::class, Network::class] as $model) { + $storefront = $model::select(['uuid', 'company_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('key', $id); + }) + ->first(); + + if ($storefront) { + return $storefront; + } + } + + return null; + } + + protected static function companyPublicId(?string $companyUuid): ?string + { + if (!$companyUuid) { + return null; + } + + return Company::where('uuid', $companyUuid)->value('public_id'); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..5cec3919 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -135,6 +135,7 @@ function ($router) { $router->get('/', 'CustomerController@query'); $router->post('register-device', 'CustomerController@registerDevice'); $router->post('unregister-device', 'CustomerController@unregisterDevice'); + $router->post('socket-token', 'CustomerController@socketToken'); $router->get('places', 'CustomerController@places'); $router->get('orders', 'CustomerController@orders'); $router->get('{id}', 'CustomerController@find'); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 9113ec35..7d02eee8 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -468,6 +468,34 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note } } +class CheckoutChannelPayloadStub extends TestableCheckoutController +{ + public static bool $failSerialization = false; + + protected static function checkoutChannelOrder(Fleetbase\FleetOps\Models\Order $order): array + { + if (static::$failSerialization) { + throw new RuntimeException('Order serialization failed'); + } + + return ['id' => $order->public_id]; + } + + public static function initializedCheckoutResponse(Checkout $checkout, array $data) + { + return static::checkoutResponse($checkout, $data); + } +} + +function enableCheckoutSocketAuth(bool $enabled = true): void +{ + config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'checkout-socket-test-key-0123456789abcdef' : null]); +} + +afterEach(function () { + enableCheckoutSocketAuth(false); +}); + function createCheckoutBoundarySchema(): void { $connection = Model::getConnectionResolver()->connection('mysql'); @@ -2794,7 +2822,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode CheckoutQPayStub::$sandboxUsed = false; CheckoutQPayStub::$authenticated = false; Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; - $controller = new TestableCheckoutController(); + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); $missingInvoice = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ 'checkout' => 'checkout_abcdefgh', @@ -2857,7 +2886,243 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($sandboxError->getData(true)['error']['error'])->toBe('PAYMENT_NOT_PAID') ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() ->and(CheckoutQPayStub::$authenticated)->toBeTrue() - ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toHaveCount(3); + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + // The realtime payload carries what a storefront client acts on — never the raw payment row. + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'failed', + 'order' => null, + 'error' => [ + 'error' => 'PAYMENT_NOT_PAID', + 'message' => 'Payment has not been paid!', + ], + ]], + ]); +}); + +test('qpay callback publishes paid before an order exists and survives a failed publish', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'order_uuid', + 'public_id' => 'order_abcdefgh', + ]); + $connection->table('checkouts')->insert([ + [ + 'uuid' => 'checkout_pending_uuid', + 'public_id' => 'checkout_pending', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => null, + 'options' => '{}', + 'token' => 'checkout-token-pending', + ], + [ + 'uuid' => 'checkout_ordered_uuid', + 'public_id' => 'checkout_ordered', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'order_uuid', + 'options' => '{}', + 'token' => 'checkout-token-ordered', + ], + ]); + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); + + $paidWithoutOrder = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_pending', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = true; + $publishFailed = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_ordered', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = false; + + expect($paidWithoutOrder->getData(true)['payment']['payment_status'])->toBe('PAID') + // A failed publish never turns a recorded payment into an error response. + ->and($publishFailed->getStatusCode())->toBe(200) + ->and($publishFailed->getData(true)['payment']['payment_status'])->toBe('PAID') + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + ['checkout.checkout_pending', [ + 'checkout' => 'checkout_pending', + 'status' => 'paid', + 'order' => null, + 'error' => null, + ]], + ]); +}); + +test('qpay callback publishes the order serialized as checkout status returns it', function () { + createCheckoutCaptureExecutionSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'status_order_uuid', + 'public_id' => 'order_status', + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_abcdefgh', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'status_order_uuid', + 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']), + 'token' => 'checkout-token', + 'captured' => true, + ]); + session(['storefront_key' => null]); + CheckoutQPayStub::$failure = null; + CheckoutQPayStub::$paymentCheckResult = (object) [ + 'count' => 1, + 'rows' => [ + (object) [ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_wallet' => 'QPay', + ], + ], + ]; + TestableCheckoutController::$statusFallbackOrder = null; + TestableCheckoutController::$statusFallbackFailure = null; + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + + (new TestableCheckoutController())->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_abcdefgh', + ])); + $published = Fleetbase\Support\SocketCluster\SocketClusterService::$published; + + expect($published)->toHaveCount(1) + ->and($published[0][0])->toBe('checkout.checkout_abcdefgh') + ->and(array_keys($published[0][1]))->toBe(['checkout', 'status', 'order', 'error']) + ->and($published[0][1]['status'])->toBe('completed') + ->and($published[0][1]['order']['id'])->toBe('order_status') + ->and($published[0][1]['error'])->toBeNull(); +}); + +test('initialized checkouts carry a checkout-scoped socket token only while socket auth is enabled', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('companies')->insert([ + 'uuid' => 'company_uuid', + 'public_id' => 'company_public', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => 'store_uuid', + 'storefront_network' => null, + ]); + $initialize = function () { + $cart = new Cart(); + $cart->forceFill([ + 'uuid' => 'cart_uuid', + 'currency' => 'USD', + 'items' => [['id' => 'line_one', 'quantity' => 1, 'subtotal' => 1000]], + 'events' => [], + ]); + $customer = new Fleetbase\Storefront\Models\Customer(); + $customer->forceFill(['uuid' => 'customer_uuid']); + $gateway = Gateway::cash(); + $gateway->forceFill(['uuid' => 'gateway_uuid']); + + return CheckoutController::initializeCashCheckout( + $customer, + $gateway, + null, + $cart, + (object) ['is_pickup' => true, 'tip' => false, 'delivery_tip' => false], + Request::create('/checkout') + ); + }; + + $disabled = $initialize(); + enableCheckoutSocketAuth(); + $enabled = $initialize(); + $checkout = Checkout::where('public_id', $enabled->getData(true)['checkout'])->firstOrFail(); + $socket = $enabled->getData(true)['socket_token']; + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($socket['token']); + + expect(array_keys($disabled->getData(true)))->toBe(['checkout', 'token']) + ->and(array_keys($enabled->getData(true)))->toBe(['checkout', 'token', 'socket_token']) + ->and(array_keys($socket))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe($checkout->uuid) + ->and($claims->ids)->toBe([$checkout->uuid, $checkout->public_id]) + ->and($claims->scp)->toBe(['checkout.' . $checkout->public_id]) + ->and($claims->cid)->toBe('company_uuid') + ->and($claims->cpid)->toBe('company_public') + ->and($claims->sid)->toBe('store_uuid') + ->and($claims->env)->toBe('live'); +}); + +test('checkout response helper adds the socket token beside the existing fields', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_network', + 'company_uuid' => 'company_uuid', + 'network_uuid' => 'network_uuid', + 'token' => 'checkout-token', + ]); + $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail(); + enableCheckoutSocketAuth(); + + $data = CheckoutChannelPayloadStub::initializedCheckoutResponse($checkout, ['invoice' => ['invoice_id' => 'inv'], 'checkout' => 'checkout_network', 'token' => 'checkout-token'])->getData(true); + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($data['socket_token']['token']); + + expect($data['invoice'])->toBe(['invoice_id' => 'inv']) + ->and($claims->scp)->toBe(['checkout.checkout_network']) + ->and($claims->sid)->toBe('network_uuid') + ->and($claims->cpid)->toBeNull(); }); test('single and multiple order capture reject invalid checkout tokens safely', function () { diff --git a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php index d71c6aac..485d25c6 100644 --- a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php +++ b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php @@ -100,6 +100,11 @@ protected function mergeConfigFrom($path, $key) { $this->calls[] = $key; } + + public function registerStorefrontSocketChannels(): void + { + $this->calls[] = 'socket-channels'; + } }; $provider->boot(); @@ -119,5 +124,17 @@ protected function mergeConfigFrom($path, $key) 'database.connections', 'storefront', 'storefront.api', + 'socket-channels', ]); }); + +test('storefront provider registers its socket channel resolvers with the core registry', function () { + $app = new Fleetbase\TestSupport\ApplicationContainer(); + $registry = new Fleetbase\Support\SocketCluster\SocketChannelRegistry(); + $app->instance(Fleetbase\Support\SocketCluster\SocketChannelRegistry::class, $registry); + + (new StorefrontServiceProvider($app))->registerStorefrontSocketChannels(); + + expect($registry->resolve('storefront'))->toBeInstanceOf(Closure::class) + ->and($registry->resolve('checkout'))->toBeInstanceOf(Closure::class); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..b6a0dc2f 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -92,6 +92,7 @@ private function record(string $method, string $uri, mixed $action): self ['GET', 'about', 'StoreController@about'], ['POST', '/', 'ProductController@create'], ['POST', 'receipt', 'OrderController@getReceipt'], + ['POST', 'socket-token', 'CustomerController@socketToken'], ['POST', 'send-push-notification', 'ActionController@sendPushNotification'], ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php new file mode 100644 index 00000000..aa7ec375 --- /dev/null +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -0,0 +1,350 @@ + $enabled ? 'storefront-socket-test-key-0123456789abcdef' : null]); +} + +function storefrontSocketSchema(): void +{ + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['companies', 'contacts', 'personal_access_tokens', 'stores', 'networks', 'checkouts'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->string('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('backdrop_uuid')->nullable(); + $table->string('logo_uuid')->nullable(); + $table->string('order_config_uuid')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->text('translations')->nullable(); + $table->string('website')->nullable(); + $table->string('facebook')->nullable(); + $table->string('instagram')->nullable(); + $table->string('twitter')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->text('tags')->nullable(); + $table->string('currency')->nullable(); + $table->string('timezone')->nullable(); + $table->string('pod_method')->nullable(); + $table->text('options')->nullable(); + $table->text('alertable')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $schema->create('checkouts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('network_uuid')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->text('options')->nullable(); + $table->string('token')->nullable(); + $table->string('order_uuid')->nullable(); + $table->boolean('captured')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $connection->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaaaaaa'], + ['uuid' => 'company-b', 'public_id' => 'company_bbbbbbb'], + ]); + $connection->table('stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'store_key_a'], + ['uuid' => 'store-a2', 'public_id' => 'store_aaaaaa2', 'company_uuid' => 'company-a', 'key' => 'store_key_a2'], + ['uuid' => 'store-b', 'public_id' => 'store_bbbbbbb', 'company_uuid' => 'company-b', 'key' => 'store_key_b'], + ['uuid' => 'store-orphan', 'public_id' => 'store_orphan1', 'company_uuid' => null, 'key' => 'store_key_orphan'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], + ]); + $connection->table('contacts')->insert([ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'type' => 'customer'], + ]); + $connection->table('checkouts')->insert([ + ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], + ['uuid' => 'checkout-guest', 'public_id' => 'chkt_guest01', 'company_uuid' => 'company-a', 'store_uuid' => null, 'network_uuid' => 'network-a', 'owner_uuid' => null, 'token' => 'checkout_g'], + ['uuid' => 'checkout-b', 'public_id' => 'chkt_bbbbbbb', 'company_uuid' => 'company-b', 'store_uuid' => 'store-b', 'network_uuid' => null, 'owner_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'token' => 'checkout_b'], + ['uuid' => 'checkout-orphan', 'public_id' => 'chkt_orphan1', 'company_uuid' => null, 'store_uuid' => null, 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_o'], + ]); + + foreach (['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' => 'customer-secret-a', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' => 'customer-secret-b'] as $contact => $secret) { + $connection->table('personal_access_tokens')->insert([ + 'name' => $contact, + 'token' => hash('sha256', $secret), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} + +function storefrontSocketTokenRequest(?string $storefrontKey, ?string $customerToken): Request +{ + $request = Request::create('/storefront/v1/customers/socket-token', 'POST'); + $request->setLaravelSession(new SessionStore('storefront-socket-test', new ArraySessionHandler(120))); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + app()->instance('request', $request); + session(['storefront_key' => $storefrontKey]); + + return $request; +} + +function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrincipal +{ + return SocketPrincipal::fromClaims(array_merge(['kind' => $kind, 'sub' => $kind . '-subject', 'cid' => 'company-a'], $claims)); +} + +beforeEach(function () { + storefrontSocketSchema(); + storefrontSocketEnable(false); +}); + +afterEach(function () { + storefrontSocketEnable(false); + session(['storefront_key' => null]); +}); + +test('customer socket token is not found while socket auth is disabled', function () { + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Not found.']); +}); + +test('customer socket token requires an authenticated customer of the storefront company', function () { + storefrontSocketEnable(); + $controller = new CustomerController(); + + $noCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', null)); + $unknownCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', 'not-a-real-token')); + $noStorefront = $controller->socketToken(storefrontSocketTokenRequest(null, 'customer-secret-a')); + $otherCompany = $controller->socketToken(storefrontSocketTokenRequest('store_key_b', 'customer-secret-a')); + + foreach ([$noCustomer, $unknownCustomer, $noStorefront, $otherCompany] as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'Not authorized to create a socket token for customer.']); + } +}); + +test('customer socket token mints a customer principal scoped to the store', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + $body = $response->getData(true); + $claims = SocketToken::verify($body['token']); + + expect($response->getStatusCode())->toBe(200) + ->and(array_keys($body))->toBe(['token', 'expires_in', 'expires_at']) + ->and($body['expires_in'])->toBe(900) + ->and($claims)->toBeInstanceOf(SocketPrincipal::class) + ->and($claims->kind)->toBe('customer') + ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa']) + ->and($claims->cid)->toBe('company-a') + ->and($claims->cpid)->toBe('company_aaaaaaa') + ->and($claims->sid)->toBe('store-a') + ->and($claims->env)->toBe('live') + ->and($claims->adm)->toBeFalse() + ->and($claims->scp)->toBeNull(); +}); + +test('customer socket token minted with a network key is scoped to the network', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('network_key_a', 'customer-secret-a')); + $claims = SocketToken::verify($response->getData(true)['token']); + + expect($claims->kind)->toBe('customer') + ->and($claims->sid)->toBe('network-a') + ->and($claims->cid)->toBe('company-a'); +}); + +test('checkout principal is limited by scope to its own checkout channel', function () { + $storeCheckout = Checkout::where('uuid', 'checkout-a')->firstOrFail(); + $networkCheckout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + $orphanCheckout = Checkout::where('uuid', 'checkout-orphan')->firstOrFail(); + + $store = StorefrontSocket::checkoutPrincipal($storeCheckout); + $network = StorefrontSocket::checkoutPrincipal($networkCheckout); + $orphan = StorefrontSocket::checkoutPrincipal($orphanCheckout); + + expect($store->kind)->toBe('checkout') + ->and($store->sub)->toBe('checkout-a') + ->and($store->ids)->toBe(['checkout-a', 'chkt_aaaaaaa']) + ->and($store->cid)->toBe('company-a') + ->and($store->cpid)->toBe('company_aaaaaaa') + ->and($store->scp)->toBe(['checkout.chkt_aaaaaaa']) + ->and($store->sid)->toBe('store-a') + ->and($store->env)->toBe('live') + ->and($network->sid)->toBe('network-a') + ->and($network->scp)->toBe(['checkout.chkt_guest01']) + ->and($orphan->cid)->toBeNull() + ->and($orphan->cpid)->toBeNull() + ->and(StorefrontSocket::checkoutChannel($storeCheckout))->toBe('checkout.chkt_aaaaaaa'); +}); + +test('checkout socket token is only minted while socket auth is enabled', function () { + $checkout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + + $disabled = StorefrontSocket::checkoutToken($checkout); + storefrontSocketEnable(); + $enabled = StorefrontSocket::checkoutToken($checkout); + $claims = SocketToken::verify($enabled['token']); + + expect($disabled)->toBeNull() + ->and(array_keys($enabled))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe('checkout-guest') + ->and($claims->scp)->toBe(['checkout.chkt_guest01']) + ->and($claims->cid)->toBe('company-a'); +}); + +test('storefront channels resolve stores and networks by uuid public id or key within the company', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + + expect(StorefrontSocket::authorizeStorefront($user, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_key_a', 'storefront.store_key_a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($api, 'network_aaaaaaa', 'storefront.network_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_bbbbbbb', 'storefront.store_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_orphan1', 'storefront.store_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_missing', 'storefront.store_missing'))->toBeFalse(); +}); + +test('storefront channels let a customer subscribe only to the storefront their token names', function () { + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $networkMember = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'network-a']); + $unscoped = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'cid' => 'company-b', 'sid' => 'store-a']); + $driver = storefrontSocketPrincipal('driver', ['sid' => 'store-a']); + + expect(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaa2', 'storefront.store_aaaaaa2'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'network-a', 'storefront.network-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($unscoped, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($foreign, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($driver, 'store-a', 'storefront.store-a'))->toBeFalse(); +}); + +test('checkout channels allow the company and the owning customer only', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + $owner = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $stranger = storefrontSocketPrincipal('customer', ['sub' => 'contact-other', 'sid' => 'store-a']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'cid' => 'company-b', 'sid' => 'store-b']); + $checkout = storefrontSocketPrincipal('checkout', ['sub' => 'checkout-a', 'scp' => ['checkout.chkt_aaaaaaa']]); + $driver = storefrontSocketPrincipal('driver'); + + expect(StorefrontSocket::authorizeCheckout($user, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($api, 'checkout-a', 'checkout.checkout-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_orphan1', 'checkout.chkt_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_missing', 'checkout.chkt_missing'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_guest01', 'checkout.chkt_guest01'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($stranger, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($foreign, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($checkout, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($driver, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse(); +}); + +test('storefront registers its storefront and checkout channel resolvers', function () { + $registry = new SocketChannelRegistry(); + + StorefrontSocket::registerChannels($registry); + + $storefront = $registry->resolve('storefront'); + $checkout = $registry->resolve('checkout'); + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + + expect($storefront)->toBeInstanceOf(Closure::class) + ->and($checkout)->toBeInstanceOf(Closure::class) + ->and($storefront($customer, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and($checkout($customer, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and($checkout($customer, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse(); +}); + +test('customer principal is built from the customer and the storefront of the key', function () { + $customer = Contact::where('uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')->firstOrFail(); + $store = Store::where('uuid', 'store-a')->firstOrFail(); + $network = Network::where('uuid', 'network-a')->firstOrFail(); + + $forStore = StorefrontSocket::customerPrincipal($customer, $store); + $forNetwork = StorefrontSocket::customerPrincipal($customer, $network); + + expect($forStore->toClaims())->toBe([ + 'kind' => 'customer', + 'sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'cid' => 'company-a', + 'cpid' => 'company_aaaaaaa', + 'env' => 'live', + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa'], + 'adm' => false, + 'sid' => 'store-a', + ])->and($forNetwork->sid)->toBe('network-a'); +});