diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index e7ab6fbd..cde939be 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -1,10 +1,18 @@ name: API Contract (Postman) -# Boots a full Fleetbase stack (published image) and runs the Storefront API -# Postman collection against the live API. Delegates to the reusable workflow in +# Boots a full Fleetbase stack (published image) and runs the Storefront API Postman +# collection against the live API. Delegates to the reusable workflow in # fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN # (inherited); no-ops until POSTMAN_API_KEY is set. -# TODO: change @dev-v0.7.53 to @main once that branch is merged. +# +# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main +# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and +# there is no ref here to remember to bump. Each run records the image digest it actually +# resolved in its job summary, so a result stays traceable. To reproduce an older run: +# +# with: +# fleetbase-ref: v0.7.53 +# api-image: fleetbase/fleetbase-api:v0.7.53 on: push: @@ -12,14 +20,17 @@ on: pull_request: branches: [main] workflow_dispatch: - permissions: contents: read jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main with: collections: "Fleetbase Storefront API" build-from-source: false + # Without this the run tests the version of fleetbase/storefront-api baked into the + # published image, not the branch under review. The workflow checks this + # repository out at the commit under test and swaps it into the container. + overlay-package: fleetbase/storefront-api secrets: inherit diff --git a/README.md b/README.md index bee60dd4..d36b49ef 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,17 @@ The public customer-facing API is mounted under `storefront/v1` and protected by - Customer registration, login, SMS code verification, social login endpoints, device registration, saved places, customer orders, phone verification, Stripe customer helpers, and account closure flows. - Order pickup completion and receipt generation. +### Marketplace network contract + +A public `network_...` key uses accepted `network_stores` membership as its marketplace authorization boundary, including member stores owned by another company. Marketplace responses expose only public Storefront resources; they do not expose member customers, internal API keys, or gateway credentials. + +- `GET storefront/v1/stores` supports search, online/tag/category/ID filters, rating/age/popularity/trending/nearest sorts, distance filtering in meters, and limit/offset pagination. +- Categories and published products can be scoped to a member store. Product responses include public merchant context for marketplace search and cards. +- Cart line items preserve both merchant and store-location identity. Product and location substitution across stores is rejected. +- Checkout revalidates active membership, store availability, published products, location ownership, the network multi-cart policy, and one-cart currency before payment begins. +- Network delivery quotes resolve one origin per represented store and reject missing, foreign, or mismatched locations with `422`. +- When `Customer-Token` is present, it is authoritative; a conflicting checkout customer ID returns `403`. + ### Internal Console API The protected internal API is mounted under `storefront/int/v1`. It powers the Fleetbase Console and includes: @@ -204,6 +215,15 @@ composer test:unit composer test ``` +Generate and summarize the full backend Clover baseline with Xdebug or PCOV: + +```bash +XDEBUG_MODE=coverage composer test:coverage:clover +composer coverage:summary +``` + +Marketplace changes on `dev-v0.4.19` must retain 100% backend statement coverage. Focused success and failure-path contracts should be added before regenerating the baseline. + ## Configuration Storefront configuration is provided through the Laravel package config files and environment variables: diff --git a/RELEASE.md b/RELEASE.md index ada2c890..df539b28 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,15 +1,47 @@ -> v0.4.18 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title" +> v0.4.19 ~ "Marketplace storefronts with safer checkout and customer verification" --- ## Highlights -RELEASE_NOTES_PLACEHOLDER +Storefront networks can now power a multi-merchant marketplace. Network clients can discover member stores, locations, categories, products, tags, reviews, and payment gateways while the API keeps every result inside the active marketplace. -Describe what changed in this release. The first line above must name the version -being released, and both placeholder markers must be gone, or the release workflow -refuses to tag. +Carts and checkout now validate merchant membership, store locations, product availability, online status, currency, and the network's multi-store policy. Delivery quotes preserve one origin per merchant, and cart responses include merchant details without querying each line separately. + +--- +## Security and Reliability + +- Authenticated checkout now treats the `Customer-Token` identity as authoritative and rejects a conflicting customer ID with `403`. +- The app-review verification bypass is disabled by default and only works for explicitly allowlisted email addresses or phone numbers. +- Switching between store and network keys clears the previous storefront scope, preventing filters from leaking between requests. +- Store, category, product, location, and review lookups are constrained to the active storefront context. +- Invalid cart, location, review, Apple sign-in, and SMS-provider states now return controlled API errors instead of internal exceptions. + +--- +## API and Checkout Changes + +- Network store discovery supports search, category and tag filters, online state, ratings, popularity, trending activity, age, nearest distance, and maximum distance. +- Product creation now defaults to `published`, matching the console, and marketplace reads only return published, available products. +- Checkout accepts both `serviceQuote` and the documented `service_quote` field. +- Cash, card, and payment-intent checkout responses now include the `checkout` public ID alongside the existing token so clients can query checkout status. +- A cart that has already produced an order can no longer be mutated; retrieving its old ID creates a fresh cart. +- SMS login and account-closure requests fall back to email when SMS is unavailable, and responses identify the delivery method. + +--- +## Upgrade Steps + +No database migration is required. + +Installations that use a fixed code for app-store review accounts must now configure both values; the previous `999000` default no longer works: + +```dotenv +STOREFRONT_BYPASS_VERIFICATION_CODE= +STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100 +``` + +Release the matching Storefront SDK and API specification before distributing marketplace-enabled app builds. Publish the corresponding documentation with the backend release. --- ## Need help? + - [GitHub Discussions](https://github.com/fleetbase/fleetbase/discussions) - [Discord](https://discord.gg/HnTqQ6zAVn) diff --git a/composer.json b/composer.json index 353c5663..b2543c06 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/storefront-api", - "version": "0.4.18", + "version": "0.4.19", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index 0bcc1757..b14d8932 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Storefront", - "version": "0.4.18", + "version": "0.4.19", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "repository": "https://github.com/fleetbase/storefront", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index c7789323..164e3eb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/storefront-engine", - "version": "0.4.18", + "version": "0.4.19", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "fleetbase": { "route": "storefront", diff --git a/server/config/storefront.php b/server/config/storefront.php index 9312c57e..7161a4c8 100644 --- a/server/config/storefront.php +++ b/server/config/storefront.php @@ -25,7 +25,24 @@ |-------------------------------------------------------------------------- */ 'storefront_app' => [ - 'bypass_verification_code' => env('STOREFRONT_BYPASS_VERIFICATION_CODE', '999000') + /* + | App store reviewers cannot receive our SMS or email, so a fixed verification + | code has to keep working in production for them. Both values are required for + | a bypass to be possible, and neither has a default — an unconfigured install + | has no bypass at all. + | + | The code alone is NOT sufficient: it is only accepted for an identity listed + | in review_accounts. Previously any identity was accepted, so knowing the code + | was enough to authenticate as any customer. + | + | STOREFRONT_BYPASS_VERIFICATION_CODE= + | STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100 + */ + 'bypass_verification_code' => env('STOREFRONT_BYPASS_VERIFICATION_CODE'), + 'review_accounts' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('STOREFRONT_REVIEW_ACCOUNTS', '')) + ))), ], /* diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index cff27471..bb240e1b 100644 --- a/server/src/Http/Controllers/NetworkController.php +++ b/server/src/Http/Controllers/NetworkController.php @@ -31,6 +31,7 @@ public function findNetwork(string $id) { $id = trim($id); $isPublicId = Str::startsWith($id, ['storefront_network_', 'network_']); + $network = null; if ($isPublicId) { $network = Network::where('public_id', $id)->first(); diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php index c1f00af3..af5aa9fb 100644 --- a/server/src/Http/Controllers/v1/CartController.php +++ b/server/src/Http/Controllers/v1/CartController.php @@ -9,20 +9,39 @@ class CartController extends Controller { - protected function retrieveCart(?string $uniqueId, bool $create = false): Cart + /** + * The flag used to be passed through as `$create`, but Cart::retrieve()'s second + * parameter is `$excludeCheckedout`. Every mutating action below took the `false` + * default and so happily added to, updated, emptied or deleted a cart that had + * already produced an order, while the GET action passed `true` and did not. The + * name made that read as intentional. Let Cart::retrieve() keep its own default so + * a checked-out cart is off limits everywhere. + */ + protected function retrieveCart(?string $uniqueId): Cart { - return Cart::retrieve($uniqueId, $create); + return Cart::retrieve($uniqueId); } /** * Retrieve or create a cart using a unique identifier. If no unique identifier is provided * one will be created. * + * The injected Request is declared FIRST on purpose. Laravel resolves method + * dependencies by splicing class-typed parameters in at their own index and filling + * the remainder from the route parameters, in order. With the optional $uniqueId + * first, GET /storefront/v1/carts (the route with no {uniqueId}) had nothing to put + * at index 0, so the Request landed at index 1 and the call arrived with a hole: + * + * ArgumentCountError: Too few arguments to function retrieve(), 1 passed + * + * The route WITH an id worked, which is why this looked like a cart problem rather + * than a signature one. Five requests in the Storefront collection failed behind it. + * * @return \Illuminate\Http\Response */ - public function retrieve(?string $uniqueId = null, Request $request) + public function retrieve(Request $request, ?string $uniqueId = null) { - $cart = $this->retrieveCart($uniqueId, true); + $cart = $this->retrieveCart($uniqueId); return new StorefrontCart($cart); } diff --git a/server/src/Http/Controllers/v1/CategoryController.php b/server/src/Http/Controllers/v1/CategoryController.php index 54d7ae74..4d46be93 100644 --- a/server/src/Http/Controllers/v1/CategoryController.php +++ b/server/src/Http/Controllers/v1/CategoryController.php @@ -47,7 +47,12 @@ public function query(Request $request) })->toArray(); // get all products in these categories - $products = Product::whereIn('category_uuid', $categoryIds)->where('is_available', 1)->with(['addonCategories', 'variants', 'files'])->get(); + $products = Product::whereIn('category_uuid', $categoryIds) + ->where('store_uuid', session('storefront_store')) + ->where('is_available', 1) + ->where('status', 'published') + ->with(['addonCategories', 'variants', 'files']) + ->get(); $results = $results->map(function ($category) use ($products) { $category->products = $products->where('category_uuid', $category->uuid)->mapInto(ProductResource::class)->values(); @@ -60,7 +65,6 @@ public function query(Request $request) if (session('storefront_network')) { if ($request->filled('store')) { $store = Store::where([ - 'company_uuid' => session('company'), 'public_id' => $request->input('store'), ])->whereHas('networks', function ($q) { $q->where('network_uuid', session('storefront_network')); @@ -91,7 +95,12 @@ public function query(Request $request) })->toArray(); // get all products in these categories - $products = Product::whereIn('category_uuid', $categoryIds)->where('is_available', 1)->with(['addonCategories', 'variants', 'files'])->get(); + $products = Product::whereIn('category_uuid', $categoryIds) + ->where('store_uuid', $store->uuid) + ->where('is_available', 1) + ->where('status', 'published') + ->with(['addonCategories', 'variants', 'files']) + ->get(); $results = $results->map(function ($category) use ($products) { // $category->products = Product::where('category_uuid', $category->uuid)->get()->mapInto(ProductResource::class); @@ -120,11 +129,21 @@ public function query(Request $request) // if we want to get categories with stores if ($request->has('with_stores')) { - $results = $results->map(function ($category) { - $category->stores = Store::whereHas('networks', function ($q) use ($category) { - $q->where('network_uuid', session('storefront_network')); - $q->where('category_uuid', $category->uuid); - })->get(); + $categoryIds = $results->pluck('uuid'); + $stores = Store::whereHas('networks', function ($q) use ($categoryIds) { + $q->where('network_uuid', session('storefront_network')); + $q->whereIn('category_uuid', $categoryIds); + })->with(['networks' => function ($q) use ($categoryIds) { + $q->where('network_uuid', session('storefront_network')); + $q->whereIn('category_uuid', $categoryIds); + }])->get(); + + $results = $results->map(function ($category) use ($stores) { + $category->stores = $stores->filter(function ($store) use ($category) { + return $store->networks->contains(function ($network) use ($category) { + return $network->pivot?->category_uuid === $category->uuid; + }); + })->values(); return $category; }); diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 375f96a3..4ca11f8a 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -21,6 +21,7 @@ use Fleetbase\Storefront\Models\Customer; use Fleetbase\Storefront\Models\FoodTruck; use Fleetbase\Storefront\Models\Gateway; +use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Models\StoreLocation; @@ -123,6 +124,72 @@ protected function resolveStoreLocationOrigin($origin, Cart $cart) return $storeLocation ? $storeLocation->place_uuid : null; } + protected function validateMarketplaceCart(Cart $cart) + { + $networkUuid = session('storefront_network'); + if (!$networkUuid) { + return null; + } + + $items = collect($cart->items); + if ($items->isEmpty()) { + return response()->apiError('The cart is empty.', 422); + } + + $storeIds = $items->pluck('store_id')->filter()->unique()->values(); + if ($storeIds->count() !== $items->pluck('store_id')->unique()->count()) { + return response()->apiError('Every marketplace cart item must identify its store.', 422); + } + + $stores = Store::whereIn('public_id', $storeIds) + ->whereHas('networks', fn ($query) => $query->where('network_uuid', $networkUuid)) + ->get() + ->keyBy('public_id'); + + if ($stores->count() !== $storeIds->count()) { + return response()->apiError('The cart contains a store outside this marketplace.', 403); + } + + if ($stores->contains(fn (Store $store) => !$store->online)) { + return response()->apiError('A store in this cart is currently offline.', 422); + } + + $network = Network::select(['uuid', 'options'])->where('uuid', $networkUuid)->first(); + $multiCartEnabled = data_get($network, 'options.multi_cart_enabled') === true; + if ($storeIds->count() > 1 && !$multiCartEnabled) { + return response()->apiError('This marketplace only supports one store per cart.', 422); + } + + $products = Product::whereIn('public_id', $items->pluck('product_id')->filter()->unique()) + ->whereIn('store_uuid', $stores->pluck('uuid')) + ->where('is_available', 1) + ->where('status', 'published') + ->get() + ->keyBy('public_id'); + $locations = StoreLocation::whereIn('public_id', $items->pluck('store_location_id')->filter()->unique()) + ->whereIn('store_uuid', $stores->pluck('uuid')) + ->get() + ->keyBy('public_id'); + + foreach ($items as $item) { + $store = $stores->get($item->store_id ?? null); + $product = $products->get($item->product_id ?? null); + $location = $locations->get($item->store_location_id ?? null); + if (!$store || !$product || $product->store_uuid !== $store->uuid) { + return response()->apiError('A product in this cart is no longer available from its store.', 422); + } + if (!$location || $location->store_uuid !== $store->uuid) { + return response()->apiError('A store location in this cart is no longer valid.', 422); + } + } + + if ($products->pluck('currency')->filter()->unique()->count() > 1) { + return response()->apiError('Marketplace carts cannot combine different currencies.', 422); + } + + return null; + } + protected function resolveFoodTruck(Cart $cart): ?FoodTruck { return collect($cart->items) @@ -163,6 +230,47 @@ protected function applyFoodTruckOrderData(?FoodTruck $foodTruck, array $orderMe return [$orderMeta, $orderInput]; } + /** + * Resolves the customer a checkout is for. + * + * The customer used to be taken from the request body and trusted. A storefront + * key is client-side by nature — it ships inside the storefront app — and customer + * public ids appear in ordinary API responses, so anyone holding a key could check + * out as an arbitrary customer simply by passing their id. + * + * When a Customer-Token is present it now wins, and a body parameter naming a + * different customer is refused rather than silently honoured. Guest checkout is + * unaffected: with no token the body parameter is still used, since a guest has no + * token to present. + * + * @return Customer|\Illuminate\Http\JsonResponse|null + */ + protected static function resolveCheckoutCustomer(?string $customerId) + { + $authenticated = Storefront::getCustomerFromToken(); + + // Guest checkout — no token to check against. + if (!$authenticated) { + return $customerId ? Customer::findFromCustomerId($customerId) : null; + } + + if ($customerId) { + // A storefront customer is stored as a Contact, and findFromCustomerId() + // rewrites a customer_ prefix to contact_ before looking it up. Compare in + // that same space, or a caller's own customer_xxxx would never match the + // contact_xxxx on their record and every authenticated checkout would 403. + $normalized = Str::startsWith($customerId, 'customer') + ? Str::replaceFirst('customer', 'contact', $customerId) + : $customerId; + + if ($authenticated->public_id !== $normalized) { + return response()->apiError('Customer does not match the authenticated session.', 403); + } + } + + return Customer::findFromCustomerId($authenticated->public_id) ?? $authenticated; + } + public function beforeCheckout(InitializeCheckoutRequest $request) { $gatewayCode = $request->input('gateway'); @@ -183,9 +291,16 @@ public function beforeCheckout(InitializeCheckoutRequest $request) ]); // find and validate cart session - $cart = Cart::retrieve($cartId); + $cart = Cart::retrieve($cartId); + $cartValidation = $this->validateMarketplaceCart($cart); + if ($cartValidation) { + return $cartValidation; + } $gateway = Storefront::findGateway($gatewayCode); - $customer = Customer::findFromCustomerId($customerId); + $customer = static::resolveCheckoutCustomer($customerId); + if ($customer instanceof \Illuminate\Http\JsonResponse) { + return $customer; + } $serviceQuote = ServiceQuote::select(['amount', 'meta', 'uuid', 'public_id'])->where('public_id', $serviceQuoteId)->first(); // handle cash orders @@ -254,8 +369,12 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa 'cart_state' => $cart->toArray(), ]); + // `checkout` is the chkt_* public id and `token` is a separate checkout_* value. + // 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. return response()->json([ - 'token' => $checkout->token, + 'checkout' => $checkout->public_id, + 'token' => $checkout->token, ]); } @@ -351,11 +470,14 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate 'cart_state' => $cart->toArray(), ]); + // 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 response()->json([ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, 'customerId' => $customer->getMeta('stripe_id'), + 'checkout' => $checkout->public_id, 'token' => $checkout->token, ]); } @@ -373,7 +495,10 @@ public function createStripeSetupIntentForCustomer(CreateStripeSetupIntentReques return response()->apiError('Gateway not configured correctly!'); } - $customer = Customer::findFromCustomerId($customerId); + $customer = static::resolveCheckoutCustomer($customerId); + if ($customer instanceof \Illuminate\Http\JsonResponse) { + return $customer; + } \Stripe\Stripe::setApiKey($gateway->config->secret_key); @@ -472,7 +597,10 @@ public function updateStripePaymentIntent(Request $request) } // @codeCoverageIgnoreEnd - $customer = Customer::findFromCustomerId($customerId); + $customer = static::resolveCheckoutCustomer($customerId); + if ($customer instanceof \Illuminate\Http\JsonResponse) { + return $customer; + } if (!$customer) { return response()->apiError('Invalid customer ID provided'); } @@ -568,12 +696,14 @@ public function updateStripePaymentIntent(Request $request) 'cart_state' => $cart->toArray(), ]); - // Return JSON response with updated PaymentIntent and ephemeral key + // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is + // the chkt_* public id GET /checkouts/status requires alongside the token. return response()->json([ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, 'customerId' => $customer->getMeta('stripe_id'), + 'checkout' => $checkout->public_id, 'token' => $checkout->token, ]); } diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 3a38bacc..d9ef326f 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -25,6 +25,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; class CustomerController extends Controller @@ -175,13 +176,23 @@ public function create(CreateCustomerRequest $request) $code = $request->input('code'); $about = Storefront::about(['company_uuid']); $input = $request->only(['name', 'type', 'title', 'email', 'phone', 'meta']); - $identity = $request->input('identity'); $user = null; - if (!Utils::isEmail($identity)) { + // The code was filed against whatever identity requestCustomerCreationCode was + // given. A client that just verified an address and now posts it as `email` should + // not have to repeat it as `identity` — fall back to the payload before giving up. + // Without this, a body of {name, email, code} left $identity null, static::phone() + // turned it into the literal '+', and a perfectly good code was rejected. + $identity = $request->input('identity') ?: $request->input('email') ?: $request->input('phone'); + + if ($identity && !Utils::isEmail($identity)) { $identity = static::phone($identity); } + if (blank($identity)) { + return response()->apiError('An identity is required to create a customer.'); + } + // verify code $verificationCode = VerificationCode::where(['code' => $code, 'for' => 'storefront_create_customer', 'meta->identity' => $identity])->exists(); if (!$verificationCode) { @@ -414,7 +425,13 @@ public function login(Request $request) $password = $request->input('password'); $attrs = $request->input(['name', 'phone', 'email']); - $user = User::where('email', $identity)->orWhere('phone', static::phone($identity))->first(); + // Guard the phone branch: with no identity to format, static::phone() returns null, + // and `where('phone', null)` compiles to `phone IS NULL` — which would match an + // arbitrary phone-less user rather than nobody. + $identityPhone = static::phone($identity); + $user = User::where('email', $identity) + ->when($identityPhone, fn ($query) => $query->orWhere('phone', $identityPhone)) + ->first(); if (!$user || !Hash::check($password, $user->password)) { return response()->apiError('Authentication failed using password provided.', 401); @@ -461,6 +478,12 @@ public function loginWithPhone() { $phone = static::phone(); + // Without a phone in the request there is nothing to look up. Falling through would + // compile to `phone IS NULL` and hand back an arbitrary phone-less user. + if (!$phone) { + return response()->apiError('No customer with this phone # found.'); + } + // check if user exists $user = User::where('phone', $phone)->whereNull('deleted_at')->withoutGlobalScopes()->first(); @@ -471,14 +494,47 @@ public function loginWithPhone() // get the storefront or network logging in for $about = Storefront::about(); - // generate verification token - VerificationCode::generateSmsVerificationFor($user, 'storefront_login', [ - 'messageCallback' => function ($verification) use ($about) { - return "Your {$about->name} verification code is {$verification->code}"; - }, - ]); + // Generate the verification token. + // + // The SMS attempt is guarded because the Twilio SDK THROWS when the store has no + // credentials configured — ConfigurationException, "Credentials are required to + // create a Client" — and that propagated as a 500 carrying an HTML stack trace. + // A store that has simply not set up SMS is not a server error. + // + // Falling back to email mirrors FleetOps' DriverController::loginWithPhone, and + // means a store without Twilio can still authenticate its customers. `method` + // tells the client which channel actually carried the code. + $messageCallback = function ($verification) use ($about) { + return "Your {$about->name} verification code is {$verification->code}"; + }; - return response()->json(['status' => 'OK']); + try { + VerificationCode::generateSmsVerificationFor($user, 'storefront_login', [ + 'messageCallback' => $messageCallback, + ]); + + return response()->json(['status' => 'OK', 'method' => 'sms']); + } catch (\Throwable $e) { + if (app()->bound('sentry')) { + app('sentry')->captureException($e); + } + + if ($user->email) { + try { + VerificationCode::generateEmailVerificationFor($user, 'storefront_login', [ + 'messageCallback' => $messageCallback, + ]); + + return response()->json(['status' => 'OK', 'method' => 'email']); + } catch (\Throwable $e) { + if (app()->bound('sentry')) { + app('sentry')->captureException($e); + } + } + } + } + + return response()->apiError('Unable to send verification code.'); } /** @@ -517,8 +573,25 @@ public function loginWithApple(Request $request) } try { - // Verify the Apple token using the utility function - $isValid = $this->verifyAppleIdentity($identityToken); + // Verify the Apple token using the utility function. + // + // A malformed identityToken is client input, not a server fault, but the JWT + // parser throws rather than returning false — so a bad token fell through to + // the catch at the end of this method and came back as + // 500 {"error":"The JWT string must have two dots"} + // leaking the parser's own message. Any client sending a truncated or expired + // token hit this. It is the same rejection as a token that parses but does not + // verify, so it gets the same 400. + try { + $isValid = $this->verifyAppleIdentity($identityToken); + } catch (\Throwable $verificationException) { + Log::warning('[Storefront] Apple identity token could not be parsed.', [ + 'exception' => $verificationException->getMessage(), + ]); + + $isValid = false; + } + if (!$isValid) { return response()->apiError('Apple ID authentication is not valid.', 400); } @@ -723,6 +796,46 @@ public function loginWithGoogle(Request $request) * * @return \Fleetbase\Http\Resources\Storefront\Customer */ + /** + * Whether a verification code should be accepted as an app-review bypass. + * + * App store reviewers cannot receive our SMS or email, so a fixed code has to keep + * working in production. It used to be compared against the submitted code alone, + * which meant anyone who learned it could authenticate as ANY customer. It is now + * only honoured for an identity explicitly listed in storefront.review_accounts, + * and both the code and the list must be configured. + * + * @param string|null $identity email or phone the caller is authenticating as + * @param mixed $code the submitted verification code + */ + protected static function isReviewAccountBypass(?string $identity, $code): bool + { + $bypassCode = config('storefront.storefront_app.bypass_verification_code'); + if (blank($bypassCode) || blank($code) || blank($identity)) { + return false; + } + + $reviewAccounts = array_map( + static fn ($account) => strtolower(trim((string) $account)), + (array) config('storefront.storefront_app.review_accounts', []) + ); + + if (!in_array(strtolower(trim($identity)), $reviewAccounts, true)) { + return false; + } + + // hash_equals so a wrong code cannot be recovered by timing the response. + if (!hash_equals((string) $bypassCode, (string) $code)) { + return false; + } + + Log::warning('[Storefront] Verification bypass accepted for a review account.', [ + 'identity' => $identity, + ]); + + return true; + } + public function verifyCode(Request $request) { $identity = Utils::isEmail($request->identity) ? $request->identity : static::phone($request->identity); @@ -734,6 +847,13 @@ public function verifyCode(Request $request) return $this->create($request); } + // Without an identity there is nobody to verify. The lookup below would compile to + // `phone IS NULL OR email IS NULL` and pick an arbitrary user to test the code + // against. + if (blank($identity)) { + return response()->apiError('Unable to verify code.'); + } + // check if user exists $user = User::where('phone', $identity)->orWhere('email', $identity)->first(); @@ -743,7 +863,7 @@ public function verifyCode(Request $request) // find and verify code $verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists(); - if (!$verificationCode && $code !== config('storefront.storefront_app.bypass_verification_code')) { + if (!$verificationCode && !static::isReviewAccountBypass($identity, $code)) { return response()->apiError('Invalid verification code!'); } @@ -792,12 +912,19 @@ protected function verifyGoogleIdentity(string $idToken, string $clientId): ?arr /** * Patches phone number with international code. */ - public static function phone(?string $phone = null): string + public static function phone(?string $phone = null): ?string { if ($phone === null) { $phone = request()->input('phone'); } + // With nothing to format this used to return a bare '+', which was then written + // into contacts.phone and users.phone for every customer created without one, and + // used as a verification-code lookup key that could never match. + if (blank($phone)) { + return null; + } + if (!Str::startsWith($phone, '+')) { $phone = '+' . $phone; } @@ -898,31 +1025,57 @@ public function startAccountClosure(Request $request) return response()->apiError('Customer account must have a valid email or phone number linked.'); } - // Send account closure confirmation with code - try { - if ($user->phone) { + // The identity the code is filed under MUST match what confirmAccountClosure looks + // it up by — `$user->phone ?? $user->email` — regardless of which channel actually + // carried it. Previously SMS filed it under the phone and email under the email, + // which agreed only because the channel was chosen by the same precedence; the + // fallback below breaks that coupling, so it is made explicit. + $identity = $user->phone ?? $user->email; + $messageCallback = function ($verification) use ($about) { + return "Your {$about->name} account closure verification code is {$verification->code}"; + }; + + // The SMS attempt is guarded rather than sharing one try with the email branch. + // The Twilio SDK throws when the store has no credentials configured, and the old + // `if phone / elseif email` meant a customer WITH a phone never reached the email + // branch — the request just returned the SDK's own message, "Credentials are + // required to create a Client", to the client. + $sent = false; + + if ($user->phone) { + try { VerificationCode::generateSmsVerificationFor($user, 'storefront_account_closure', [ - 'messageCallback' => function ($verification) use ($about) { - return "Your {$about->name} account closure verification code is {$verification->code}"; - }, - 'meta' => ['identity' => $user->phone], + 'messageCallback' => $messageCallback, + 'meta' => ['identity' => $identity], ]); - } elseif ($user->email) { + $sent = true; + } catch (\Throwable $e) { + if (app()->bound('sentry')) { + app('sentry')->captureException($e); + } + } + } + + if (!$sent && $user->email) { + try { VerificationCode::generateEmailVerificationFor($user, 'storefront_account_closure', [ 'subject' => $about->name . ' account closure request', - 'messageCallback' => function ($verification) use ($about) { - return "Your {$about->name} account closure verification code is {$verification->code}"; - }, - 'meta' => ['identity' => $user->email], + 'messageCallback' => $messageCallback, + 'meta' => ['identity' => $identity], ]); + $sent = true; + } catch (\Throwable $e) { + if (app()->bound('sentry')) { + app('sentry')->captureException($e); + } } + } + if ($sent) { return response()->json(['status' => 'OK']); - } catch (\Exception $e) { - return response()->apiError($e->getMessage()); } - return response()->apiError('An uknown error occured attempting to close customer account.'); + return response()->apiError('Unable to send account closure verification code.'); } public function confirmAccountClosure(Request $request) @@ -949,7 +1102,7 @@ public function confirmAccountClosure(Request $request) // verify account closure code $verificationCode = VerificationCode::where(['code' => $code, 'for' => 'storefront_account_closure', 'meta->identity' => $identity])->exists(); - if (!$verificationCode && $code !== config('storefront.storefront_app.bypass_verification_code')) { + if (!$verificationCode && !static::isReviewAccountBypass($identity, $code)) { return response()->apiError('Invalid verification code provided!'); } @@ -990,6 +1143,12 @@ public function requestPhoneVerification(Request $request) return response()->apiError('No user associated with this customer.'); } + // No phone to verify. This used to arrive as the literal '+' and get as far as the + // SMS provider before failing with a credentials error. + if (!$phone) { + return response()->apiError('A phone number is required to request verification.'); + } + // Check if phone number is already used by another user $existingUser = $this->findExistingUserByPhone($phone, $user->uuid); @@ -999,6 +1158,14 @@ public function requestPhoneVerification(Request $request) $about = Storefront::about(); + // A review account verifies with the configured bypass code, so no message needs + // to be delivered — and requiring one would make the flow untestable wherever SMS + // is not configured, which is the situation this bypass exists for. Same + // allowlist + constant-time code check as every other bypass call site. + if (static::isReviewAccountBypass($phone, config('storefront.storefront_app.bypass_verification_code'))) { + return response()->json(['status' => 'ok', 'method' => 'bypass']); + } + try { VerificationCode::generateSmsVerificationFor($user, 'storefront_verify_phone', [ 'messageCallback' => function ($verification) use ($about) { @@ -1008,8 +1175,18 @@ public function requestPhoneVerification(Request $request) ]); return response()->json(['status' => 'ok']); - } catch (\Exception $e) { - return response()->apiError($e->getMessage()); + } catch (\Throwable $e) { + if (app()->bound('sentry')) { + app('sentry')->captureException($e); + } + + // Deliberately not $e->getMessage(): the Twilio SDK throws + // "Credentials are required to create a Client" when the store has no SMS + // credentials, and returning that to an API consumer leaks an internal + // detail while telling them nothing they can act on. Unlike customer login + // and account closure, there is no email fallback here — verifying a phone + // number by email would not verify anything. + return response()->apiError('Unable to send phone verification code.'); } } @@ -1049,12 +1226,28 @@ public function verifyPhoneNumber(Request $request) 'for' => 'storefront_verify_phone', ])->first(); + // The bypass leaves no code row to read the phone back from, so it comes from the + // request — which is what the caller is asking to verify in the first place. + $requestedPhone = $request->input('phone') ? static::phone($request->input('phone')) : null; + if (!$verificationCode) { + if ($requestedPhone && static::isReviewAccountBypass($requestedPhone, $code)) { + $user->update(['phone' => $requestedPhone, 'phone_verified_at' => now()]); + $customer->update(['phone' => $requestedPhone]); + + return new Customer($customer->fresh()); + } + return response()->apiError('Invalid verification code!'); } - // Get the phone number from meta - $phone = $verificationCode->meta['phone']; + // Get the phone number from meta. A row written by anything other than + // requestPhoneVerification may not carry it, and an unguarded subscript turns a + // recoverable 400 into a 500. + $phone = data_get($verificationCode->meta, 'phone'); + if (!$phone) { + return response()->apiError('Verification code is not associated with a phone number.'); + } // Update user and contact $user->update(['phone' => $phone, 'phone_verified_at' => now()]); diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index f9746080..d6514f73 100644 --- a/server/src/Http/Controllers/v1/NetworkController.php +++ b/server/src/Http/Controllers/v1/NetworkController.php @@ -25,15 +25,17 @@ public function stores(Request $request) return response()->error('Stores cannot have stores!'); } - $sort = $request->input('sort', false); - $limit = $request->input('limit', false); - $offset = $request->input('offset', false); - $ids = $request->input('ids', []); - $tagged = $request->input('tagged', []); - $query = $request->input('query', false); - $location = $request->input('location'); - $maxDistance = $request->input('maximum_distance', null); - $exclude = $request->input('exclude', []); + $sort = $request->input('sort', false); + $limit = $request->input('limit', false); + $offset = $request->input('offset', false); + $ids = $request->input('ids', []); + $tagged = $request->input('tagged', []); + $searchQuery = $request->input('query', false); + $location = $request->input('location'); + $maxDistance = $request->input('maximum_distance', null); + $exclude = $request->input('exclude', []); + $coordinates = Utils::getPointFromCoordinates($location); + $requiresDistancePostProcessing = $coordinates instanceof Point && ($sort === 'nearest' || is_numeric($maxDistance)); if (is_string($tagged)) { $tagged = explode(',', $tagged); @@ -49,8 +51,7 @@ public function stores(Request $request) /** @var \Illuminate\Database\Query\Builder $query */ $query = Store::select('*') - ->where('company_uuid', session('company')) - ->with(['logo', 'backdrop', 'media']) + ->with(['logo', 'backdrop', 'media', 'locations.place']) ->whereHas('locations') ->whereHas('networks', function ($q) use ($request) { $q->where('network_uuid', session('storefront_network')); @@ -62,12 +63,24 @@ public function stores(Request $request) // Query stores by category if ($request->filled('category')) { - $category = Category::select('uuid')->where('public_id', $request->input('category'))->first(); - $q->where('category_uuid', $category->uuid); - // $q->whereHas('category', function ()) + $category = Category::select('uuid') + ->where('public_id', $request->input('category')) + ->where('owner_uuid', session('storefront_network')) + ->where('for', 'storefront_network') + ->first(); + + $q->where('category_uuid', $category?->uuid ?? '__missing_network_category__'); } }); + if ($searchQuery) { + $query->search($searchQuery); + } + + if ($request->has('online')) { + $query->where('online', $request->boolean('online')); + } + // query stores using tags provided if (!empty($tagged)) { $query->where(function ($q) use ($tagged) { @@ -102,64 +115,62 @@ public function stores(Request $request) switch ($sort) { case 'highest_rated': $query->withAvg('reviews', 'rating')->orderByDesc('reviews_avg_rating'); - // no break + break; case 'lowest_rated': $query->withAvg('reviews', 'rating')->orderBy('reviews_avg_rating'); - // no break + break; case 'newest': $query->orderByDesc('created_at'); - // no break + break; case 'oldest': $query->orderBy('created_at'); - // no break + break; case 'popular': $query->withCount('checkouts')->orderByDesc('checkouts_count'); + break; + case 'trending': + $query->withCount([ + 'checkouts as recent_checkouts_count' => fn ($checkoutQuery) => $checkoutQuery->where('created_at', '>=', now()->subDay()), + ])->orderByDesc('recent_checkouts_count'); + break; } - if ($limit) { + if ($limit && !$requiresDistancePostProcessing) { $query->limit($limit); } - if ($offset) { + if ($offset && !$requiresDistancePostProcessing) { $query->offset($offset); } $stores = $query->get(); // handle nearest sort special case due to location depth - if ($sort === 'nearest' && $location) { - $coordinates = Utils::getPointFromCoordinates($location); - - $stores = $stores->sort(function ($storeA, $storeB) use ($coordinates) { - $distanceA = $storeA->locations->sort(function ($locationA, $locationB) use ($coordinates) { - $distanceA = Utils::vincentyGreatCircleDistance($coordinates, $locationA->place->location); - $distanceB = Utils::vincentyGreatCircleDistance($coordinates, $locationB->place->location); - - return $distanceA - $distanceB; - })->map(function ($location) use ($coordinates) { - $location->distance = Utils::vincentyGreatCircleDistance($coordinates, $location->place->location); - - return $location; - })->first()->distance; - - $distanceB = $storeB->locations->sort(function ($locationA, $locationB) use ($coordinates) { - $distanceA = Utils::vincentyGreatCircleDistance($coordinates, $locationA->place->location); - $distanceB = Utils::vincentyGreatCircleDistance($coordinates, $locationB->place->location); - - return $distanceA - $distanceB; - })->map(function ($location) use ($coordinates) { - $location->distance = Utils::vincentyGreatCircleDistance($coordinates, $location->place->location); - - return $location; - })->first()->distance; - - return $distanceA - $distanceB; - }); - } - - // sort trending ( most checkouts within 24h ) - if ($sort === 'trending') { - $stores = $stores->sortByDesc('24h_checkouts_count'); + if ($requiresDistancePostProcessing) { + $stores = $stores->map(function ($store) use ($coordinates) { + $nearestLocation = $store->locations + ->filter(fn ($storeLocation) => $storeLocation->place?->location instanceof Point) + ->map(function ($storeLocation) use ($coordinates) { + $storeLocation->distance = Utils::vincentyGreatCircleDistance($coordinates, $storeLocation->place->location); + + return $storeLocation; + }) + ->sortBy('distance') + ->first(); + + $store->distance = $nearestLocation?->distance; + + return $store; + })->when(is_numeric($maxDistance), function ($collection) use ($maxDistance) { + return $collection->filter(fn ($store) => is_numeric($store->distance) && $store->distance <= (float) $maxDistance); + })->when($sort === 'nearest', fn ($collection) => $collection->sortBy(fn ($store) => $store->distance ?? PHP_FLOAT_MAX))->values(); + + if ($offset) { + $stores = $stores->slice((int) $offset)->values(); + } + if ($limit) { + $stores = $stores->take((int) $limit)->values(); + } } return StorefrontStore::collection($stores); @@ -172,6 +183,13 @@ public function stores(Request $request) */ public function storeLocations(Request $request) { + $storeUuid = session('storefront_store'); + $networkUuid = session('storefront_network'); + + if (!$storeUuid && !$networkUuid) { + return response()->error('Store locations require a storefront context!'); + } + $limit = $request->input('limit', 30); $ids = $request->input('ids', []); $exclude = $request->input('exclude', []); @@ -199,10 +217,12 @@ public function storeLocations(Request $request) $query = StoreLocation::select(['store_locations.*', $placesTableName . '.location', $placesTableName . '.uuid as place_uuid']) ->join($placesTableName, $placesTableName . '.uuid', '=', 'store_locations.place_uuid') - ->whereHas('store', function ($q) use ($tagged, $searchQuery) { - $q->whereHas('networks', function ($q) { - $q->where('network_uuid', session('storefront_network')); - }); + ->whereHas('store', function ($q) use ($storeUuid, $networkUuid, $tagged, $searchQuery) { + if ($storeUuid) { + $q->where('uuid', $storeUuid); + } else { + $q->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', $networkUuid)); + } if (!empty($tagged)) { $q->where(function ($q) use ($tagged) { @@ -218,7 +238,7 @@ public function storeLocations(Request $request) }); if ($shouldIncludeStore) { - $query->with('store'); + $query->with(['store.logo', 'store.backdrop']); } // if we need to exclude specific stores @@ -259,13 +279,22 @@ public function storeLocations(Request $request) */ public function tags(Request $request) { + $storeUuid = session('storefront_store'); + $networkUuid = session('storefront_network'); + + if (!$storeUuid && !$networkUuid) { + return response()->error('Tags require a storefront context!'); + } + $tags = []; $stores = Store::select(['tags']) ->whereHas('locations') - ->whereHas('networks', function ($q) { - $q->where('network_uuid', session('storefront_network')); - })->get(); + ->when($storeUuid, fn ($query) => $query->where('uuid', $storeUuid)) + ->when($networkUuid, function ($query) use ($networkUuid) { + $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', $networkUuid)); + }) + ->get(); foreach ($stores as $store) { $tags = array_merge($tags, $store->tags ?? []); diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php index 456ec44c..01a95663 100644 --- a/server/src/Http/Controllers/v1/ProductController.php +++ b/server/src/Http/Controllers/v1/ProductController.php @@ -68,6 +68,15 @@ public function create(CreateProductRequest $request) // Set currency $input['currency'] = data_get($input, 'currency', session('storefront_currency', 'USD')); + // Default the status the way the console does. The column is nullable with no + // default, so an API-created product used to land as NULL and was then invisible to + // every `where('status', 'published')` read path — including the cart validation in + // CheckoutController, which dropped it at capture time. + // Literal rather than Product::PUBLISHED. The constant does not resolve under the + // unit-test harness (reproducible on the whole suite, not just an isolated file), + // and the rest of this controller writes the status literally too. + $input['status'] = data_get($input, 'status') ?: 'published'; + // Resolve category if ($request->filled('category')) { $categoryInput = $request->input('category'); @@ -341,11 +350,20 @@ public function query(Request $request) $nq->where('network_uuid', session('storefront_network')); }); }); + + if ($request->filled('store')) { + $query->whereHas('store', fn ($storeQuery) => $storeQuery->where('public_id', $request->input('store'))); + } } - // @todo When done dev is completed make sure status is published - also add status field to product view $query->where('is_available', 1); - $query->with(['addonCategories.category', 'variants.options', 'files']); + $query->where('status', 'published'); + $relations = ['addonCategories.category', 'variants.options', 'files']; + if ($request->boolean('with_store') || $request->inArray('with', 'store')) { + $relations[] = 'store.logo'; + $relations[] = 'store.backdrop'; + } + $query->with($relations); if ($request->filled('category')) { $category = Category::where(['public_id' => $request->input('category'), 'for' => 'storefront_product'])->first(); @@ -366,11 +384,20 @@ public function query(Request $request) */ public function find($id) { - // find for the product - try { - $product = Product::findRecordOrFail($id); - } catch (ModelNotFoundException $exception) { - return response()->error('Product resource not found.'); + $product = Product::where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->when(session('storefront_store'), fn ($query) => $query->where('store_uuid', session('storefront_store'))) + ->when(session('storefront_network'), function ($query) { + $query->whereHas('store.networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $query->where('is_available', 1); + $query->where('status', 'published'); + }) + ->with(['addonCategories.category', 'variants.options', 'files']) + ->first(); + + if (!$product) { + return response()->error('Product resource not found.', 404); } // response the product resource diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index 90a69ecc..dd51ba78 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -8,6 +8,7 @@ use Fleetbase\Models\File; use Fleetbase\Storefront\Http\Requests\CreateReviewRequest; use Fleetbase\Storefront\Http\Resources\Review as StorefrontReview; +use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Support\Storefront; @@ -17,6 +18,58 @@ class ReviewController extends Controller { + protected function resolveStoreForContext(string $id): ?Store + { + return Store::where('public_id', $id) + ->when(session('storefront_store'), fn ($query) => $query->where('uuid', session('storefront_store'))) + ->when(session('storefront_network'), function ($query) { + $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + }) + ->first(); + } + + protected function findScopedReview(string $id): ?Review + { + return Review::where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->when(session('storefront_store'), function ($query) { + $storeUuid = session('storefront_store'); + $query->where(function ($subjectQuery) use ($storeUuid) { + $subjectQuery->where('subject_uuid', $storeUuid) + ->orWhereIn('subject_uuid', Product::select('uuid')->where('store_uuid', $storeUuid)); + }); + }) + ->when(session('storefront_network'), function ($query) { + $memberStoreUuids = Store::select('uuid') + ->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $memberProductUuids = Product::select('uuid')->whereIn('store_uuid', clone $memberStoreUuids); + $query->where(function ($subjectQuery) use ($memberStoreUuids, $memberProductUuids) { + $subjectQuery->whereIn('subject_uuid', $memberStoreUuids) + ->orWhereIn('subject_uuid', $memberProductUuids); + }); + }) + ->first(); + } + + protected function subjectBelongsToContext($subject): bool + { + if ($subject instanceof Store) { + return (bool) $this->resolveStoreForContext($subject->public_id); + } + + if ($subject instanceof Product) { + return Product::where('uuid', $subject->uuid) + ->when(session('storefront_store'), fn ($query) => $query->where('store_uuid', session('storefront_store'))) + ->when(session('storefront_network'), function ($query) { + $query->whereHas('store.networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + }) + ->exists(); + } + + return false; + } + /** * Query for Storefront Review resources. * @@ -49,12 +102,7 @@ public function query(Request $request) if (session('storefront_network')) { if ($request->filled('store')) { - $store = Store::where([ - 'company_uuid' => session('company'), - 'public_id' => $request->input('store'), - ])->whereHas('networks', function ($q) { - $q->where('network_uuid', session('storefront_network')); - })->first(); + $store = $this->resolveStoreForContext($request->input('store')); if (!$store) { return response()->json(['error' => 'Cannot find reviews for store'], 400); @@ -68,7 +116,7 @@ public function query(Request $request) } if ($offset) { - $query->limit($offset); + $query->offset($offset); } }); } @@ -130,12 +178,7 @@ public function count(Request $request) if (session('storefront_network')) { if ($request->filled('store')) { - $store = Store::where([ - 'company_uuid' => session('company'), - 'public_id' => $request->input('store'), - ])->whereHas('networks', function ($q) { - $q->where('network_uuid', session('storefront_network')); - })->first(); + $store = $this->resolveStoreForContext($request->input('store')); if (!$store) { return response()->json(['error' => 'Cannot count reviews for store'], 400); @@ -161,7 +204,10 @@ public function find($id) { // find for the review try { - $review = Review::findRecordOrFail($id); + $review = $this->findScopedReview($id); + if (!$review) { + throw new ModelNotFoundException(); + } } catch (ModelNotFoundException $exception) { return response()->error('Review resource not found.'); } @@ -188,7 +234,7 @@ public function create(CreateReviewRequest $request) $subject = Utils::resolveSubject($request->input('subject')); - if (!$subject) { + if (!$subject || !$this->subjectBelongsToContext($subject)) { return response()->error('Invalid subject for review'); } @@ -247,11 +293,19 @@ public function delete($id) { // find for the product try { - $review = Review::findRecordOrFail($id); + $review = $this->findScopedReview($id); + if (!$review) { + throw new ModelNotFoundException(); + } } catch (ModelNotFoundException $exception) { return response()->error('Review resource not found.'); } + $customer = Storefront::getCustomerFromToken(); + if (!$customer || $review->customer_uuid !== $customer->uuid) { + return response()->error('Not authorized to delete review', 403); + } + // delete the review $review->delete(); diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php index 03407fc1..cee388a0 100644 --- a/server/src/Http/Controllers/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php @@ -209,35 +209,62 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) $currency = $cart->currency; - // collect stores - $storeLocations = collect($cart->items)->map(function ($cartItem) { - $storeLocationId = $cartItem->store_location_id; - - // if no store location id set, use first locations id - if (!$storeLocationId) { - $store = Store::where('public_id', $cartItem->store_id)->first(); - - if ($store) { - $storeLocationId = Utils::get($store, 'locations.0.public_id'); - } - } - - return $storeLocationId; - })->unique()->filter()->map(function ($storeLocationId) { - return StoreLocation::where('public_id', $storeLocationId)->with(['store', 'place'])->first(); - }); + // Resolve explicit and default origins in bulk. This avoids querying the + // store and its locations once for every cart line. + $cartItems = collect($cart->items ?? []); + $defaultStoreIds = $cartItems + ->filter(fn ($cartItem) => !data_get($cartItem, 'store_location_id') && data_get($cartItem, 'store_id')) + ->map(fn ($cartItem) => data_get($cartItem, 'store_id')) + ->unique() + ->values(); + $defaultLocationIds = Store::whereIn('public_id', $defaultStoreIds) + ->whereHas('networks', fn ($query) => $query->where('network_uuid', session('storefront_network'))) + ->with(['locations' => fn ($query) => $query->orderBy('id')]) + ->get() + ->mapWithKeys(fn ($store) => [$store->public_id => data_get($store, 'locations.0.public_id')]); + $storeLocationIds = $cartItems + ->map(function ($cartItem) use ($defaultLocationIds) { + return data_get($cartItem, 'store_location_id') ?: $defaultLocationIds->get(data_get($cartItem, 'store_id')); + }) + ->unique() + ->filter() + ->values(); // fallback store locations using origin param - if ($storeLocations->isEmpty()) { + if ($storeLocationIds->isEmpty()) { $storeLocationIds = $request->input('origin', []); if (is_string($storeLocationIds) && Str::contains($storeLocationIds, ',')) { $storeLocationIds = explode(',', $storeLocationIds); } + $storeLocationIds = collect($storeLocationIds)->unique()->filter()->values(); + } - $storeLocations = collect($storeLocationIds)->unique()->filter()->map(function ($storeLocationId) { - return StoreLocation::where('public_id', $storeLocationId)->with(['store', 'place'])->first(); - }); + $storeLocations = StoreLocation::whereIn('public_id', $storeLocationIds) + ->whereHas('store.networks', fn ($query) => $query->where('network_uuid', session('storefront_network'))) + ->with(['store', 'place']) + ->get() + ->sortBy(fn ($storeLocation) => $storeLocationIds->search($storeLocation->public_id)) + ->values(); + + if ($storeLocationIds->isEmpty() || $storeLocations->count() !== $storeLocationIds->count()) { + return response()->error('One or more store locations are unavailable for this marketplace.', 422); + } + + $storeLocationsById = $storeLocations->keyBy('public_id'); + $hasMismatchedStore = $cartItems->contains(function ($cartItem) use ($defaultLocationIds, $storeLocationsById) { + $storeId = data_get($cartItem, 'store_id'); + if (!$storeId) { + return false; + } + + $storeLocationId = data_get($cartItem, 'store_location_id') ?: $defaultLocationIds->get($storeId); + + return data_get($storeLocationsById->get($storeLocationId), 'store.public_id') !== $storeId; + }); + + if ($hasMismatchedStore) { + return response()->error('One or more store locations are unavailable for this marketplace.', 422); } // get origins diff --git a/server/src/Http/Controllers/v1/StoreController.php b/server/src/Http/Controllers/v1/StoreController.php index 9c47e349..bc1ae560 100644 --- a/server/src/Http/Controllers/v1/StoreController.php +++ b/server/src/Http/Controllers/v1/StoreController.php @@ -51,12 +51,23 @@ public function lookup(?string $id) return response()->apiError('No ID provided for lookup.'); } - $store = Store::where(['public_id' => $id, 'company_uuid' => session('company')])->first(); + $store = Store::where('public_id', $id) + ->when(session('storefront_network'), function ($query) { + $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + }) + ->when(session('storefront_store'), function ($query) { + $activeStore = session('storefront_store'); + $query->where(fn ($storeQuery) => $storeQuery->where('uuid', $activeStore)->orWhere('public_id', $activeStore)); + }) + ->first(); if ($store) { return new StorefrontResource($store); } - $network = Network::where(['public_id' => $id, 'company_uuid' => session('company')])->first(); + $network = Network::where('public_id', $id) + ->when(session('storefront_network'), fn ($query) => $query->where('uuid', session('storefront_network'))) + ->when(session('storefront_store'), fn ($query) => $query->where('company_uuid', session('company'))) + ->first(); if ($network) { return new NetworkResource($network); } @@ -78,6 +89,14 @@ public function locations(Request $request) if ($request->filled('store')) { $locations = StoreLocation::whereHas('store', function ($q) use ($request) { $q->where('public_id', $request->input('store')); + + if (session('storefront_network')) { + $q->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + } + + if (session('storefront_store')) { + $q->where('uuid', session('storefront_store')); + } })->with(['place', 'hours'])->get(); } else { $locations = StoreLocation::where('store_uuid', session('storefront_store'))->with(['place', 'hours'])->get(); @@ -98,7 +117,25 @@ public function location(string $id, Request $request) } $storeId = $request->input('store', session('storefront_store')); - $store = Store::where('public_id', $storeId)->orWhere('uuid', $storeId)->first(); + $store = Store::where(function ($query) use ($storeId) { + $query->where('public_id', $storeId)->orWhere('uuid', $storeId); + }) + ->when(session('storefront_network'), function ($query) { + $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + }) + ->when(session('storefront_store'), function ($query) { + $activeStore = session('storefront_store'); + $query->where(fn ($storeQuery) => $storeQuery->where('uuid', $activeStore)->orWhere('public_id', $activeStore)); + }) + ->first(); + + // Both lookups were unguarded, so an id that resolved nothing was handed straight + // to the resource and blew up inside it — GET /storefront/v1/locations/{id} + // answered 500 with an HTML stack trace for any unknown location, which is what a + // client sees for a stale or mistyped id. 404 is the correct answer. + if (!$store) { + return response()->error('Unable to find store!', 404); + } $location = StoreLocation::where([ 'public_id' => $id, @@ -107,6 +144,10 @@ public function location(string $id, Request $request) ->with(['place', 'hours']) ->first(); + if (!$location) { + return response()->error('Store location not found!', 404); + } + return new StoreLocationResource($location); } @@ -193,7 +234,12 @@ public function search(Request $request) $categories = Category::where(['company_uuid' => session('company'), 'for' => 'storefront_product'])->search($searchQuery)->get(); if ($categories) { foreach ($categories as $category) { - $categoryProducts = Product::where('category_uuid', $category->uuid)->get(); + $categoryProducts = Product::where([ + 'category_uuid' => $category->uuid, + 'store_uuid' => session('storefront_store'), + 'is_available' => 1, + 'status' => 'published', + ])->get(); $results = $results->merge($categoryProducts)->unique('uuid'); } } @@ -202,6 +248,9 @@ public function search(Request $request) } $results = Product::findFromNetwork($searchQuery, $store, $limit); + if ($request->boolean('with_store') || $request->inArray('with', 'store')) { + $results->load(['store.logo', 'store.backdrop']); + } return ProductResource::collection($results); } diff --git a/server/src/Http/Middleware/SetStorefrontSession.php b/server/src/Http/Middleware/SetStorefrontSession.php index c787aee2..0f56bfb0 100644 --- a/server/src/Http/Middleware/SetStorefrontSession.php +++ b/server/src/Http/Middleware/SetStorefrontSession.php @@ -53,7 +53,19 @@ public function isValidKey(string $key): bool */ public function setKey(string $key): void { - $session = ['storefront_key' => $key]; + // Clear whichever scope the previous key established before writing this one. + // Sessions here are cookie-backed, so a client that calls a network endpoint and + // then a store endpoint reuses the same session: leaving the old keys in place + // left BOTH storefront_store and storefront_network set, and every query that + // branches on them (Cart::findProduct, ProductController::find/query, ...) then + // applied the store filter AND the stricter network filter at once. + $session = [ + 'storefront_key' => $key, + 'storefront_store' => null, + 'storefront_store_public_id' => null, + 'storefront_network' => null, + 'storefront_network_public_id' => null, + ]; if (Str::startsWith($key, 'store')) { $store = Store::select(['uuid', 'public_id', 'company_uuid', 'currency'])->where('key', $key)->first(); diff --git a/server/src/Http/Requests/CreateProductRequest.php b/server/src/Http/Requests/CreateProductRequest.php index 1ed8c562..e63f5a36 100644 --- a/server/src/Http/Requests/CreateProductRequest.php +++ b/server/src/Http/Requests/CreateProductRequest.php @@ -38,7 +38,12 @@ public function rules(): array 'is_recommended' => 'nullable|boolean', 'can_pickup' => 'nullable|boolean', 'youtube_urls' => 'nullable|array', - 'status' => 'nullable|string|in:draft,active,archived', + // `published` is the status the rest of the module reads — Product::PUBLISHED, + // the network branch of Cart::findProduct(), ProductController::find/query, + // CategoryController, StoreController, and CheckoutController's cart validation + // all filter on it, and the console writes it. Leaving it out of this list meant + // a product created through the public API could never satisfy any of them. + 'status' => 'nullable|string|in:draft,active,archived,published', 'category' => 'nullable', 'addon_categories' => 'nullable|array', ]; diff --git a/server/src/Http/Requests/CreateReviewRequest.php b/server/src/Http/Requests/CreateReviewRequest.php index 270ed4ee..470ab7bf 100644 --- a/server/src/Http/Requests/CreateReviewRequest.php +++ b/server/src/Http/Requests/CreateReviewRequest.php @@ -23,7 +23,15 @@ public function authorize() */ public function rules() { + // `subject` is required, and typing it matters: the controller resolves it with + // Utils::resolveSubject(), whose parameter is a non-nullable string. A request + // without a subject therefore threw a TypeError — + // Utils::resolveSubject(): Argument #1 ($publicId) must be of type string, + // null given + // — as a 500, before the controller's own "Invalid subject for review" guard + // could run. That guard was unreachable for the commonest way to get it wrong. return [ + 'subject' => 'required|string', 'rating' => 'required|numeric', 'content' => 'required', 'files' => 'sometimes|array', diff --git a/server/src/Http/Requests/InitializeCheckoutRequest.php b/server/src/Http/Requests/InitializeCheckoutRequest.php index e06c593c..b2d74140 100644 --- a/server/src/Http/Requests/InitializeCheckoutRequest.php +++ b/server/src/Http/Requests/InitializeCheckoutRequest.php @@ -30,9 +30,14 @@ public function rules() 'gateway' => ['required', new GatewayExists()], 'customer' => ['required', new CustomerExists()], 'cart' => ['required', 'exists:storefront.carts,public_id'], - 'serviceQuote' => [Rule::requiredIf(fn () => !$this->boolean('pickup')), 'exists:service_quotes,public_id'], - 'cash' => ['sometimes', 'boolean'], - 'pickup' => ['sometimes', 'boolean'], + // The controller reads the quote with or(['serviceQuote', 'service_quote']), so both + // spellings are supported downstream. Validating only the camelCase one rejected every + // snake_case request with "The service quote field is required." before the controller + // ever ran — including the spelling our own API reference documents. + 'serviceQuote' => [Rule::requiredIf(fn () => !$this->boolean('pickup') && !$this->filled('service_quote')), 'exists:service_quotes,public_id'], + 'service_quote' => [Rule::requiredIf(fn () => !$this->boolean('pickup') && !$this->filled('serviceQuote')), 'exists:service_quotes,public_id'], + 'cash' => ['sometimes', 'boolean'], + 'pickup' => ['sometimes', 'boolean'], ]; } } diff --git a/server/src/Http/Resources/Cart.php b/server/src/Http/Resources/Cart.php index 593e8515..840d80cf 100644 --- a/server/src/Http/Resources/Cart.php +++ b/server/src/Http/Resources/Cart.php @@ -43,12 +43,28 @@ public function getCartItems() { $items = $this->items ?? []; - return array_map(function ($cartItem) { - $product = Product::select(['uuid', 'public_id', 'primary_image_uuid', 'name', 'description'])->with(['files'])->where('public_id', data_get($cartItem, 'product_id'))->first(); + $products = Product::select(['uuid', 'public_id', 'store_uuid', 'primary_image_uuid', 'name', 'description']) + ->with(['files', 'store.logo', 'store.backdrop']) + ->whereIn('public_id', collect($items)->pluck('product_id')->filter()->unique()) + ->get() + ->keyBy('public_id'); + + return array_map(function ($cartItem) use ($products) { + $product = $products->get(data_get($cartItem, 'product_id')); if ($product) { data_set($cartItem, 'name', $product->name); data_set($cartItem, 'description', $product->description); data_set($cartItem, 'product_image_url', $product->primary_image_url); + if ($product->store) { + data_set($cartItem, 'store', [ + 'id' => $product->store->public_id, + 'name' => $product->store->name, + 'logo_url' => $product->store->logo_url, + 'backdrop_url' => $product->store->backdrop_url, + 'online' => $product->store->online, + 'currency' => $product->store->currency, + ]); + } } return $cartItem; diff --git a/server/src/Http/Resources/Product.php b/server/src/Http/Resources/Product.php index 9dced7e9..db403dbb 100644 --- a/server/src/Http/Resources/Product.php +++ b/server/src/Http/Resources/Product.php @@ -32,6 +32,7 @@ public function toArray($request) 'name' => $this->name, 'description' => $this->description, 'category' => $this->when(Http::isInternalRequest(), $this->category), + 'store' => $this->when($request->boolean('with_store') || $request->inArray('with', 'store'), new Store($this->store)), 'sku' => $this->sku, 'primary_image_url' => $this->primary_image_url, 'price' => $this->price, diff --git a/server/src/Http/Resources/Store.php b/server/src/Http/Resources/Store.php index 51efd249..f515a413 100644 --- a/server/src/Http/Resources/Store.php +++ b/server/src/Http/Resources/Store.php @@ -46,6 +46,7 @@ public function toArray($request) 'backdrop_url' => $this->backdrop_url, 'rating' => $this->rating, 'online' => $this->online, + 'distance' => $this->when(isset($this->distance), $this->distance), 'alertable' => $this->alertable, 'is_network' => false, 'is_store' => true, diff --git a/server/src/Models/Cart.php b/server/src/Models/Cart.php index c9e77166..4e65bfe8 100644 --- a/server/src/Models/Cart.php +++ b/server/src/Models/Cart.php @@ -307,6 +307,18 @@ public function addItem(Product $product, $quantity = 1, $variants = [], $addons $id = Utils::generatePublicId('cart_item'); $cartItem = new \stdClass(); + if ($storeLocationId && !Str::startsWith($storeLocationId, 'food_truck_')) { + $locationBelongsToStore = StoreLocation::where('store_uuid', $product->store_uuid) + ->where(function ($query) use ($storeLocationId) { + $query->where('public_id', $storeLocationId)->orWhere('uuid', $storeLocationId); + }) + ->exists(); + + if (!$locationBelongsToStore) { + throw new \Exception('The selected store location is not available for this product.'); + } + } + // set base price $price = Utils::numbersOnly($product->is_on_sale ? $product->sale_price : $product->price); @@ -643,6 +655,10 @@ public static function retrieve(?string $id, bool $excludeCheckedout = true): Ca $q->orWhere('unique_identifier', $id); }); + if (session('company')) { + $query->where('company_uuid', session('company')); + } + if ($excludeCheckedout) { $query->whereNull('checkout_uuid'); } @@ -684,7 +700,16 @@ public static function calculateProductSubtotal(Product $product, $quantity = 1, */ public static function findProduct(string $id): ?Product { - return Product::select(['uuid', 'store_uuid', 'public_id', 'name', 'description', 'price', 'currency', 'sale_price', 'is_on_sale', 'meta'])->where(['public_id' => $id])->with([])->first(); + return Product::select(['uuid', 'store_uuid', 'public_id', 'primary_image_uuid', 'name', 'description', 'price', 'currency', 'sale_price', 'is_on_sale', 'is_available', 'status', 'meta']) + ->where('public_id', $id) + ->when(session('storefront_store'), fn ($query) => $query->where('store_uuid', session('storefront_store'))) + ->when(session('storefront_network'), function ($query) { + $query->whereHas('store.networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $query->where('is_available', 1); + $query->where('status', 'published'); + }) + ->with(['store.locations']) + ->first(); } public function getCurrency(?string $fallbackCurrency = null): ?string diff --git a/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php index 482ced34..45d838c6 100644 --- a/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CategoryApiControllerContractsTest.php @@ -56,7 +56,9 @@ function categoryApiRequest(array $input = []): Request $table->string('uuid')->nullable(); $table->string('public_id')->nullable(); $table->string('category_uuid')->nullable(); + $table->string('store_uuid')->nullable(); $table->boolean('is_available')->default(true); + $table->string('status')->nullable(); $table->timestamp('deleted_at')->nullable(); }); $schema->create('product_addon_categories', function ($table) { @@ -107,10 +109,9 @@ function categoryApiRequest(array $input = []): Request ], ]); $connection->table('products')->insert([ - 'uuid' => 'product_uuid', - 'public_id' => 'product_public', - 'category_uuid' => 'child_uuid', - 'is_available' => true, + ['uuid' => 'product_uuid', 'public_id' => 'product_public', 'category_uuid' => 'child_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'published'], + ['uuid' => 'draft_product_uuid', 'public_id' => 'product_draft', 'category_uuid' => 'child_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'draft'], + ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'category_uuid' => 'child_uuid', 'store_uuid' => 'other_store', 'is_available' => true, 'status' => 'published'], ]); session([ 'storefront_store' => 'store_uuid', @@ -256,7 +257,9 @@ function categoryApiRequest(array $input = []): Request $table->string('uuid')->nullable(); $table->string('public_id')->nullable(); $table->string('category_uuid')->nullable(); + $table->string('store_uuid')->nullable(); $table->boolean('is_available')->default(true); + $table->string('status')->nullable(); $table->timestamp('deleted_at')->nullable(); }); $schema->create('product_addon_categories', function ($table) { @@ -307,10 +310,9 @@ function categoryApiRequest(array $input = []): Request ], ]); $connection->table('products')->insert([ - 'uuid' => 'product_uuid', - 'public_id' => 'product_public', - 'category_uuid' => 'parent_uuid', - 'is_available' => true, + ['uuid' => 'product_uuid', 'public_id' => 'product_public', 'category_uuid' => 'parent_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'published'], + ['uuid' => 'draft_product_uuid', 'public_id' => 'product_draft', 'category_uuid' => 'parent_uuid', 'store_uuid' => 'store_uuid', 'is_available' => true, 'status' => 'draft'], + ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'category_uuid' => 'parent_uuid', 'store_uuid' => 'other_store', 'is_available' => true, 'status' => 'published'], ]); session([ 'company' => 'company_uuid', diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 50954b11..8468256b 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\Storefront\Http\Controllers\v1\CheckoutController; use Fleetbase\Storefront\Http\Requests\CaptureOrderRequest; +use Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest; use Fleetbase\Storefront\Http\Requests\InitializeCheckoutRequest; use Fleetbase\Storefront\Models\Cart; use Fleetbase\Storefront\Models\Checkout; @@ -110,6 +111,16 @@ public function vendorSafely(ServiceQuote $serviceQuote, Request $request): arra class CheckoutAutomationControllerProbe extends CheckoutController { + public static function checkoutCustomer(?string $customerId) + { + return parent::resolveCheckoutCustomer($customerId); + } + + public function marketplaceCartValidation(Cart $cart) + { + return $this->validateMarketplaceCart($cart); + } + public function accept(Fleetbase\FleetOps\Models\Order $order): void { $this->autoAcceptOrder($order); @@ -146,6 +157,213 @@ public function foodTruckOrderData(?Fleetbase\Storefront\Models\FoodTruck $foodT } } +test('authenticated checkout identity cannot be replaced by a submitted customer id', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists('personal_access_tokens'); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type'); + $table->string('tokenable_id'); + $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(); + }); + $customerUuid = '1d182070-f74d-4cf6-92fb-ab35531d15c6'; + $connection->table('contacts')->insert([ + 'uuid' => $customerUuid, + 'public_id' => 'contact_authenticated', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $connection->table('personal_access_tokens')->insert([ + 'tokenable_type' => Fleetbase\Models\User::class, + 'tokenable_id' => $customerUuid, + 'name' => $customerUuid, + 'token' => hash('sha256', 'authenticated-customer-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $connection->table('gateways')->insert([ + 'uuid' => 'gateway_uuid', + 'code' => 'stripe', + 'owner_uuid' => 'store_owner_uuid', + 'type' => 'stripe', + 'config' => json_encode(['secret_key' => 'sk_test_contract']), + 'sandbox' => true, + ]); + $connection->table('carts')->insert([ + 'uuid' => 'cart_uuid', + 'public_id' => 'cart_public', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'authenticated-cart', + 'currency' => 'USD', + 'items' => '[]', + 'events' => '[]', + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => 'store_owner_uuid', + 'storefront_network' => null, + ]); + $boundRequest = Request::create('/checkout'); + $boundRequest->headers->set('Customer-Token', 'authenticated-customer-secret'); + app()->instance('request', $boundRequest); + + $resolved = CheckoutAutomationControllerProbe::checkoutCustomer('customer_authenticated'); + $resolvedContact = CheckoutAutomationControllerProbe::checkoutCustomer('contact_authenticated'); + $fallback = CheckoutAutomationControllerProbe::checkoutCustomer(null); + $mismatch = CheckoutAutomationControllerProbe::checkoutCustomer('customer_other'); + $controller = new CheckoutAutomationControllerProbe(); + $before = $controller->beforeCheckout(InitializeCheckoutRequest::create('/checkout', 'POST', [ + 'gateway' => 'stripe', + 'cart' => 'cart_public', + 'customer' => 'customer_other', + ])); + $setup = $controller->createStripeSetupIntentForCustomer(CreateStripeSetupIntentRequest::create('/checkout/stripe/setup', 'POST', [ + 'customer' => 'customer_other', + ])); + $update = $controller->updateStripePaymentIntent(Request::create('/checkout/stripe/update', 'POST', [ + 'cart' => 'cart_public', + 'customer' => 'customer_other', + ])); + app()->instance('request', Request::create('/')); + + expect($resolved?->public_id)->toBe('contact_authenticated') + ->and($resolvedContact?->public_id)->toBe('contact_authenticated') + ->and($fallback?->public_id)->toBe('contact_authenticated') + ->and($mismatch->getStatusCode())->toBe(403) + ->and($before->getStatusCode())->toBe(403) + ->and($setup->getStatusCode())->toBe(403) + ->and($update->getStatusCode())->toBe(403) + ->and($update->getData(true))->toBe([ + 'error' => 'Customer does not match the authenticated session.', + ]); +}); + +test('marketplace checkout validates membership availability locations cart mode and currency before payment', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['store_locations', 'products', 'network_stores', 'networks', 'stores'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id'); + $table->boolean('online')->default(true); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('networks', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->text('options')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('network_stores', function ($table) { + $table->increments('id'); + $table->string('network_uuid'); + $table->string('store_uuid'); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('products', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id'); + $table->string('store_uuid'); + $table->boolean('is_available')->default(true); + $table->string('status')->nullable(); + $table->string('currency')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('store_locations', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id'); + $table->string('store_uuid'); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('stores')->insert([ + ['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'online' => true], + ['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'online' => true], + ['uuid' => 'store_offline_uuid', 'public_id' => 'store_offline', 'online' => false], + ['uuid' => 'store_foreign_uuid', 'public_id' => 'store_foreign', 'online' => true], + ]); + $connection->table('networks')->insert(['uuid' => 'network_uuid', 'options' => json_encode(['multi_cart_enabled' => false])]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_one_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_two_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_offline_uuid'], + ]); + $connection->table('products')->insert([ + ['uuid' => 'product_one_uuid', 'public_id' => 'product_one', 'store_uuid' => 'store_one_uuid', 'is_available' => true, 'status' => 'published', 'currency' => 'USD'], + ['uuid' => 'product_two_uuid', 'public_id' => 'product_two', 'store_uuid' => 'store_two_uuid', 'is_available' => true, 'status' => 'published', 'currency' => 'USD'], + ['uuid' => 'product_eur_uuid', 'public_id' => 'product_eur', 'store_uuid' => 'store_two_uuid', 'is_available' => true, 'status' => 'published', 'currency' => 'EUR'], + ['uuid' => 'product_draft_uuid', 'public_id' => 'product_draft', 'store_uuid' => 'store_one_uuid', 'is_available' => true, 'status' => 'draft', 'currency' => 'USD'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'location_one_uuid', 'public_id' => 'location_one', 'store_uuid' => 'store_one_uuid'], + ['uuid' => 'location_two_uuid', 'public_id' => 'location_two', 'store_uuid' => 'store_two_uuid'], + ['uuid' => 'location_offline_uuid', 'public_id' => 'location_offline', 'store_uuid' => 'store_offline_uuid'], + ]); + + $cartFor = function (array $items): Cart { + $cart = new Cart(); + $cart->forceFill(['items' => array_map(fn ($item) => (object) $item, $items), 'events' => []]); + + return $cart; + }; + $item = fn ($store, $product, $location) => ['store_id' => $store, 'product_id' => $product, 'store_location_id' => $location]; + $controller = new CheckoutAutomationControllerProbe(); + + session(['storefront_network' => null]); + expect($controller->marketplaceCartValidation($cartFor([])))->toBeNull(); + + session(['storefront_network' => 'network_uuid']); + $empty = $controller->marketplaceCartValidation($cartFor([])); + $missingStore = $controller->marketplaceCartValidation($cartFor([$item(null, 'product_one', 'location_one')])); + $foreignStore = $controller->marketplaceCartValidation($cartFor([$item('store_foreign', 'product_one', 'location_one')])); + $offlineStore = $controller->marketplaceCartValidation($cartFor([$item('store_offline', 'product_one', 'location_offline')])); + $singleValid = $controller->marketplaceCartValidation($cartFor([$item('store_one', 'product_one', 'location_one')])); + $multiDisabled = $controller->marketplaceCartValidation($cartFor([ + $item('store_one', 'product_one', 'location_one'), + $item('store_two', 'product_two', 'location_two'), + ])); + $draftProduct = $controller->marketplaceCartValidation($cartFor([$item('store_one', 'product_draft', 'location_one')])); + $wrongLocation = $controller->marketplaceCartValidation($cartFor([$item('store_one', 'product_one', 'location_two')])); + + $connection->table('networks')->where('uuid', 'network_uuid')->update(['options' => json_encode(['multi_cart_enabled' => true])]); + $mixedCurrency = $controller->marketplaceCartValidation($cartFor([ + $item('store_one', 'product_one', 'location_one'), + $item('store_two', 'product_eur', 'location_two'), + ])); + $multiValid = $controller->marketplaceCartValidation($cartFor([ + $item('store_one', 'product_one', 'location_one'), + $item('store_two', 'product_two', 'location_two'), + ])); + + expect($empty->getStatusCode())->toBe(422) + ->and($missingStore->getStatusCode())->toBe(422) + ->and($foreignStore->getStatusCode())->toBe(403) + ->and($offlineStore->getStatusCode())->toBe(422) + ->and($singleValid)->toBeNull() + ->and($multiDisabled->getData(true))->toBe(['error' => 'This marketplace only supports one store per cart.']) + ->and($draftProduct->getData(true))->toBe(['error' => 'A product in this cart is no longer available from its store.']) + ->and($wrongLocation->getData(true))->toBe(['error' => 'A store location in this cart is no longer valid.']) + ->and($mixedCurrency->getData(true))->toBe(['error' => 'Marketplace carts cannot combine different currencies.']) + ->and($multiValid)->toBeNull(); +}); + class CheckoutIntegratedVendorProbe extends Model { public function api(): object @@ -649,6 +867,35 @@ public function dispatch($event, $payload = [], $halt = false) expect($response->getData(true))->toBe(['error' => 'No gateway configured!']); }); +test('checkout initialization stops before gateway work when marketplace cart validation fails', function () { + createCheckoutBoundarySchema(); + Model::getConnectionResolver()->connection('mysql')->table('carts')->insert([ + 'uuid' => 'marketplace_cart_uuid', + 'public_id' => 'cart_marketplace', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'marketplace-browser-cart', + 'currency' => 'USD', + 'items' => '[]', + 'events' => '[]', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + 'storefront_currency' => 'USD', + ]); + + $response = (new CheckoutController())->beforeCheckout( + InitializeCheckoutRequest::create('/checkouts/before', 'POST', [ + 'gateway' => 'missing_gateway', + 'cart' => 'marketplace-browser-cart', + ]) + ); + + expect($response->getStatusCode())->toBe(422) + ->and($response->getData(true))->toBe(['error' => 'The cart is empty.']); +}); + test('checkout automation delegates accepted and pickup-dispatched orders to storefront workflows', function () { createCheckoutBoundarySchema(); $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); @@ -656,12 +903,14 @@ public function dispatch($event, $payload = [], $halt = false) $schema->create('order_configs', function ($table) { $table->increments('id'); $table->string('uuid')->nullable(); + $table->text('flow')->nullable(); $table->text('activities')->nullable(); $table->timestamps(); $table->softDeletes(); }); Model::getConnectionResolver()->connection('mysql')->table('order_configs')->insert([ 'uuid' => 'order_config_uuid', + 'flow' => '[]', 'activities' => '[]', 'created_at' => now(), 'updated_at' => now(), @@ -842,6 +1091,7 @@ public function notify($notification): void $connection->table('carts')->insert([ 'uuid' => 'cart_uuid', 'public_id' => 'cart_abcdefgh', + 'company_uuid' => 'company_uuid', 'unique_identifier' => 'browser-cart', 'currency' => 'USD', 'items' => json_encode([ @@ -880,6 +1130,9 @@ public function notify($notification): void expect($response->getStatusCode())->toBe(200) ->and($response->getData(true)['token'])->toBe($checkout->token) + // GET /checkouts/status needs the chkt_* public id as well as the token, and only + // the QPay path used to return it. + ->and($response->getData(true)['checkout'])->toBe($checkout->public_id) ->and($checkout->owner_uuid)->toBe('customer_uuid') ->and($checkout->cart_uuid)->toBe('cart_uuid') ->and($checkout->service_quote_uuid)->toBe('quote_uuid') @@ -898,6 +1151,7 @@ public function notify($notification): void $connection->table('carts')->insert([ 'uuid' => 'cart_uuid', 'public_id' => 'cart_abcdefgh', + 'company_uuid' => 'company_uuid', 'unique_identifier' => 'browser-cart', 'currency' => 'USD', 'items' => '[]', @@ -1401,6 +1655,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($data['ephemeralKey'])->toBe('eph_secret') ->and($data['customerId'])->toBe('cus_checkout') ->and($data['token'])->toBe($checkout->token) + ->and($data['checkout'])->toBe($checkout->public_id) ->and($checkout->owner_uuid)->toBe('customer_uuid') ->and($checkout->amount)->toBe(2200) ->and($checkout->is_pickup)->toBeTrue() @@ -1567,6 +1822,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'items' => [ [ 'id' => 'line_one', + 'product_id' => null, 'name' => 'Delivery box', 'quantity' => 2, 'price' => 1000, @@ -1618,6 +1874,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode expect($data['invoice']['invoice_id'])->toBe('invoice_checkout') ->and($data['checkout'])->toBe($checkout->public_id) ->and($data['token'])->toBe($checkout->token) + ->and($data['checkout'])->toBe($checkout->public_id) ->and($checkout->amount)->toBe(2000) ->and($checkout->getOption('qpay_invoice_id'))->toBe('invoice_checkout') ->and($customer->getMeta('ebarimt_registration_no'))->toBe('1234567') @@ -1659,7 +1916,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode $controller = new CheckoutController(); $setup = $controller->createStripeSetupIntentForCustomer( - Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create( + CreateStripeSetupIntentRequest::create( '/checkout/stripe-setup', 'POST', ['customer' => 'customer_missing'] @@ -1678,7 +1935,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'config' => json_encode(['secret_key' => ' ']), ]); $blankSetup = $controller->createStripeSetupIntentForCustomer( - Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create( + CreateStripeSetupIntentRequest::create( '/checkout/stripe-setup', 'POST', ['customer' => 'customer_missing'] @@ -1754,7 +2011,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode }); $controller = new CheckoutController(); $success = $controller->createStripeSetupIntentForCustomer( - Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create( + CreateStripeSetupIntentRequest::create( '/checkout/stripe-setup', 'POST', ['customer' => 'customer_abcdefgh'] @@ -1769,7 +2026,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($successData['defaultPaymentMethod']['last4'])->toBe('4242'); $connection->table('contacts')->where('uuid', 'customer_uuid')->update(['meta' => '{}']); $createdCustomerSetup = $controller->createStripeSetupIntentForCustomer( - Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create( + CreateStripeSetupIntentRequest::create( '/checkout/stripe-setup', 'POST', ['customer' => 'customer_abcdefgh'] @@ -1800,7 +2057,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode } }); $savedMethodFailure = $controller->createStripeSetupIntentForCustomer( - Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create( + CreateStripeSetupIntentRequest::create( '/checkout/stripe-setup', 'POST', ['customer' => 'customer_abcdefgh'] @@ -1824,7 +2081,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode } }); $failure = $controller->createStripeSetupIntentForCustomer( - Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create( + CreateStripeSetupIntentRequest::create( '/checkout/stripe-setup', 'POST', ['customer' => 'customer_abcdefgh'] @@ -1908,6 +2165,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode $connection->table('carts')->insert([ 'uuid' => 'cart_uuid', 'public_id' => 'cart_abcdefgh', + 'company_uuid' => 'company_uuid', 'unique_identifier' => 'browser-cart', 'currency' => 'USD', 'items' => json_encode([ @@ -2003,6 +2261,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($updatedData['ephemeralKey'])->toBe('eph_secret') ->and($updatedData['customerId'])->toBe('cus_checkout') ->and($updatedData['token'])->toBe($checkout->token) + ->and($updatedData['checkout'])->toBe($checkout->public_id) ->and($checkout->amount)->toBe(1650) ->and($checkout->is_pickup)->toBeTrue() ->and($meta['stripe_payment_method_id'])->toBe('pm_new'); @@ -2226,7 +2485,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode $client->mode = $mode; return $controller->createStripeSetupIntentForCustomer( - Fleetbase\Storefront\Http\Requests\CreateStripeSetupIntentRequest::create( + CreateStripeSetupIntentRequest::create( '/checkout/stripe-setup', 'POST', ['customer' => 'customer_abcdefgh'] diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 5211b889..c9cba789 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -36,6 +36,11 @@ public function google(string $token, string $clientId): ?array { return $this->verifyGoogleIdentity($token, $clientId); } + + public static function reviewAccountBypass(?string $identity, mixed $code): bool + { + return parent::isReviewAccountBypass($identity, $code); + } } class PhoneConflictCustomerControllerStub extends CustomerController @@ -456,6 +461,15 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ], ]); bindCustomerNotificationDispatcher(); + $sentry = new class { + public array $exceptions = []; + + public function captureException(Throwable $exception): void + { + $this->exceptions[] = $exception; + } + }; + app()->instance('sentry', $sentry); $phoneConflictController = new PhoneConflictCustomerControllerStub(); $phoneConflictController->existingPhoneUser = new Fleetbase\Models\User(['uuid' => 'other_user_uuid']); $existingPhoneConflict = $phoneConflictController->requestPhoneVerification(Request::create('/customer/phone', 'POST', [ @@ -486,7 +500,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode $connection->statement( "CREATE TRIGGER fail_phone_verification_insert BEFORE INSERT ON verification_codes BEGIN SELECT RAISE(ABORT, 'verification insert failed'); END" ); - $phoneDeliveryFailure = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [ + $closureChannelFailure = $controller->startAccountClosure(Request::create('/customer/closure', 'POST')); + $phoneDeliveryFailure = $controller->requestPhoneVerification(Request::create('/customer/phone', 'POST', [ 'phone' => '+97699112234', ])); $connection->statement('DROP TRIGGER fail_phone_verification_insert'); @@ -508,6 +523,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'code' => $closureCode, ])); app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); + app()->forgetInstance('sentry'); expect($device->getData(true))->toHaveKey('device') ->and($connection->table('user_devices')->where('token', 'device-token')->value('user_uuid'))->toBe('user_uuid') ->and($orders->resource)->toHaveCount(1) @@ -535,6 +551,10 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'error' => 'Customer account must have a valid email or phone number linked.', ])->and($emailClosureStarted->getData(true))->toBe(['status' => 'OK']) ->and($closureDeliveryFailure->getData(true))->toHaveKey('error') + ->and($closureChannelFailure->getData(true))->toBe([ + 'error' => 'Unable to send account closure verification code.', + ]) + ->and($sentry->exceptions)->toHaveCount(4) ->and($existingPhoneConflict->getData(true))->toBe([ 'error' => 'This phone number is already associated with another account.', ]) @@ -578,7 +598,13 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($apple->getData(true))->toBe(['error' => 'Missing required Apple authentication parameters.']) ->and($google->getStatusCode())->toBe(400) ->and($google->getData(true))->toBe(['error' => 'Missing required Google authentication parameters.']) - ->and($invalidApple->getStatusCode())->toBe(500) + // A malformed identityToken is client input. The JWT parser throws rather than + // returning false, and that used to fall through to the blanket catch and come + // back as 500 {"error":"The JWT string must have two dots"} — leaking the parser's + // own message. $invalidGoogle below answers 400 for equally malformed input, so + // Apple was inconsistent with Google in this very test. This assertion pinned it. + ->and($invalidApple->getStatusCode())->toBe(400) + ->and($invalidApple->getData(true))->toBe(['error' => 'Apple ID authentication is not valid.']) ->and($rejectedApple->getData(true))->toBe(['error' => 'Apple ID authentication is not valid.']) ->and($invalidGoogle->getStatusCode())->toBe(400) ->and($invalidGoogle->getData(true))->toBe(['error' => 'Google Sign-In authentication is not valid.']); @@ -597,6 +623,23 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($probe->google('malformed-token', 'client-id'))->toBeNull(); }); +test('customer verification bypass is restricted to configured review identities and constant-time codes', function () { + config([ + 'storefront.storefront_app.bypass_verification_code' => null, + 'storefront.storefront_app.review_accounts' => [], + ]); + expect(CustomerIdentityProbe::reviewAccountBypass('reviewer@example.test', '246810'))->toBeFalse(); + + config([ + 'storefront.storefront_app.bypass_verification_code' => '246810', + 'storefront.storefront_app.review_accounts' => [' Reviewer@Example.Test '], + ]); + + expect(CustomerIdentityProbe::reviewAccountBypass('other@example.test', '246810'))->toBeFalse() + ->and(CustomerIdentityProbe::reviewAccountBypass('reviewer@example.test', 'wrong'))->toBeFalse() + ->and(CustomerIdentityProbe::reviewAccountBypass('reviewer@example.test', '246810'))->toBeTrue(); +}); + test('facebook login links an existing customer identity and issues a local access token', function () { $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); foreach (['personal_access_tokens', 'contacts', 'users'] as $table) { @@ -722,6 +765,11 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode } $linkedUser = $connection->table('users')->where('uuid', 'user_uuid')->first(); $schema->drop('users'); + $appleFailure = $socialController->loginWithApple(Request::create('/customer/apple', 'POST', [ + 'identityToken' => 'apple-failure-token', + 'authorizationCode' => 'authorization-code', + 'appleUserId' => 'apple_failure', + ])); $facebookFailure = $socialController->loginWithFacebook(Request::create('/customer/facebook', 'POST', [ 'facebookUserId' => 'facebook_failure', ])); @@ -741,6 +789,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($newApple)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) ->and($newFacebook)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) ->and($newGoogle)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) + ->and($appleFailure->getData(true))->toHaveKey('error') ->and($facebookFailure->getData(true))->toHaveKey('error') ->and($googleFailure->getData(true))->toHaveKey('error') ->and($connection->table('personal_access_tokens')->count())->toBe(6); @@ -852,6 +901,52 @@ public function message(string $to, string $message, array $media = [], array $p ]); }); +test('customer creation falls back to the payload email when no identity is sent', function () { + // A body of {name, email, code} used to leave $identity null, static::phone() turned it + // into the literal '+', and the lookup on meta->identity could never match — so a + // correctly issued code was rejected. + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists('verification_codes'); + $schema->create('verification_codes', function ($table) { + $table->increments('id'); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('verification_codes')->insert([ + 'code' => '123456', + 'for' => 'storefront_create_customer', + 'meta' => json_encode(['identity' => 'buyer@example.test']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + session(['storefront_key' => null]); + + $matched = (new CustomerController())->create( + CreateCustomerRequest::create('/customer', 'POST', [ + 'code' => '123456', + 'email' => 'buyer@example.test', + 'name' => 'Buyer', + ]) + ); + + $missing = (new CustomerController())->create( + CreateCustomerRequest::create('/customer', 'POST', [ + 'code' => '123456', + 'name' => 'Buyer', + ]) + ); + + // The fallback got past the code check — whatever it fails on next, it is no longer + // "Invalid verification code provided!". + expect(data_get($matched->getData(true), 'error'))->not->toBe('Invalid verification code provided!') + ->and($missing->getData(true))->toBe(['error' => 'An identity is required to create a customer.']); +}); + test('customer creation persists a verified storefront identity and issues an access token', function () { createCustomerVerificationDeliverySchema(); $connection = Model::getConnectionResolver()->connection('mysql'); @@ -1225,13 +1320,292 @@ public function clear(): void $response = (new CustomerController())->loginWithPhone(); app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); - expect($response->getData(true))->toBe(['status' => 'OK']) + // No phone in the request at all. static::phone() returns null rather than the bare + // '+' it used to, and `where('phone', null)` compiles to `phone IS NULL` — which would + // hand back an arbitrary phone-less user and send them a login code. + bindUnauthenticatedCustomerRequest([]); + $withoutPhone = (new CustomerController())->loginWithPhone(); + + expect($response->getData(true))->toBe(['status' => 'OK', 'method' => 'sms']) + ->and($withoutPhone->getData(true))->toBe(['error' => 'No customer with this phone # found.']) ->and($connection->table('verification_codes')->where([ 'subject_uuid' => 'user_uuid', 'for' => 'storefront_login', ])->count())->toBe(1); }); +test('customer phone login falls back to email when SMS is not configured', function () { + // The Twilio SDK THROWS when the store has no credentials — ConfigurationException, + // "Credentials are required to create a Client" — and that propagated as a 500 with an + // HTML stack trace. A store that has simply not set up SMS is not a server error, and + // it can still reach a customer who has an email address. + createCustomerVerificationDeliverySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert([ + 'uuid' => 'store_uuid', + 'public_id' => 'store_public', + 'company_uuid' => 'company_uuid', + 'key' => 'store_key', + 'name' => 'Corner Store', + ]); + $connection->table('users')->insert([ + 'uuid' => 'user_uuid', + 'name' => 'Ada Buyer', + 'phone' => '+97699112233', + 'email' => 'ada@example.test', + 'type' => 'customer', + 'created_at' => now(), + 'updated_at' => now(), + ]); + session([ + 'company' => 'company_uuid', + 'storefront_key' => 'store_key', + ]); + bindUnauthenticatedCustomerRequest(['phone' => '97699112233']); + bindCustomerNotificationDispatcher(); + + // Replace the working twilio fake with one that fails the way an unconfigured + // install does. + app()->instance('twilio', new class { + public function message(string $to, string $message, array $media = [], array $params = []): object + { + throw new RuntimeException('Credentials are required to create a Client'); + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); + + $response = (new CustomerController())->loginWithPhone(); + + // Restore the container before asserting. The whole file runs in ONE process, so + // anything left bound here leaks into every later test — including the working + // `mail.manager` that bindCustomerNotificationDispatcher() installs, which would make + // a later test's deliberately-failing email delivery succeed instead. + app()->forgetInstance('twilio'); + app()->forgetInstance('mail.manager'); + Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); + Illuminate\Support\Facades\Facade::clearResolvedInstance('mail.manager'); + app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); + + // Two rows, not one: generateSmsVerificationFor persists the code BEFORE attempting + // delivery, so the failed SMS attempt leaves its row behind and the email fallback + // adds another. Both are valid for this subject and purpose, so either verifies — + // FleetOps' driver login behaves identically. + expect($response->getData(true))->toBe(['status' => 'OK', 'method' => 'email']) + ->and($connection->table('verification_codes')->where([ + 'subject_uuid' => 'user_uuid', + 'for' => 'storefront_login', + ])->count())->toBe(2); +}); + +test('customer phone login reports both delivery failures without leaking provider exceptions', function () { + createCustomerVerificationDeliverySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert([ + 'uuid' => 'store_uuid', + 'public_id' => 'store_public', + 'company_uuid' => 'company_uuid', + 'key' => 'store_key', + 'name' => 'Corner Store', + ]); + $connection->table('users')->insert([ + 'uuid' => 'user_uuid', + 'name' => 'Ada Buyer', + 'phone' => '+97699112233', + 'email' => 'ada@example.test', + 'type' => 'customer', + 'created_at' => now(), + 'updated_at' => now(), + ]); + session([ + 'company' => 'company_uuid', + 'storefront_key' => 'store_key', + ]); + bindUnauthenticatedCustomerRequest(['phone' => '97699112233']); + $sentry = new class { + public array $exceptions = []; + + public function captureException(Throwable $exception): void + { + $this->exceptions[] = $exception; + } + }; + app()->instance('sentry', $sentry); + app()->instance('twilio', new class { + public function message(string $to, string $message, array $media = [], array $params = []): object + { + throw new RuntimeException('SMS provider unavailable'); + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); + app()->instance( + Illuminate\Contracts\Notifications\Dispatcher::class, + new class implements Illuminate\Contracts\Notifications\Dispatcher { + public function send($notifiables, $notification) + { + throw new RuntimeException('Email provider unavailable'); + } + + public function sendNow($notifiables, $notification) + { + throw new RuntimeException('Email provider unavailable'); + } + } + ); + + $response = (new CustomerController())->loginWithPhone(); + + app()->forgetInstance('sentry'); + app()->forgetInstance('twilio'); + app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); + Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); + + expect($response->getData(true))->toBe(['error' => 'Unable to send verification code.']) + ->and($sentry->exceptions)->toHaveCount(2); +}); + +test('phone verification honours the review account bypass at both ends', function () { + // The bypass exists so App Store review can complete flows that would otherwise need a + // live SMS provider. It was applied to verifyCode and confirmAccountClosure but never + // to phone verification, so that flow still required Twilio. + config([ + 'storefront.storefront_app.bypass_verification_code' => '000000', + 'storefront.storefront_app.review_accounts' => ['+97699112233'], + ]); + + createCustomerVerificationDeliverySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + // The delivery schema covers stores/companies/users/verification_codes; the + // authenticated-customer path also needs a contact and a Sanctum token. + foreach (['contacts', 'personal_access_tokens'] as $table) { + $schema->dropIfExists($table); + } + $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->string('phone')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + // verifyPhoneNumber stamps phone_verified_at, which the shared users schema omits. + $schema->table('users', function ($table) { + $table->timestamp('phone_verified_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')->nullable(); + $table->string('token')->nullable(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $connection->table('stores')->insert([ + 'uuid' => 'store_uuid', + 'public_id' => 'store_public', + 'company_uuid' => 'company_uuid', + 'key' => 'store_key', + 'name' => 'Corner Store', + ]); + $connection->table('users')->insert([ + 'uuid' => 'user_uuid', + 'name' => 'Ada Buyer', + 'email' => 'ada@example.test', + 'type' => 'customer', + 'created_at' => now(), + 'updated_at' => now(), + ]); + // A real uuid: Storefront::getCustomerFromToken() only resolves the contact from the + // token's name when Str::isUuid() passes, and silently falls through otherwise. + $connection->table('contacts')->insert([ + 'uuid' => '8f14e45f-ceea-467a-9f4d-2b5c1e0a77aa', + 'public_id' => 'customer_public', + 'company_uuid' => 'company_uuid', + 'user_uuid' => 'user_uuid', + 'type' => 'customer', + 'name' => 'Ada Buyer', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $connection->table('personal_access_tokens')->insert([ + 'name' => '8f14e45f-ceea-467a-9f4d-2b5c1e0a77aa', + 'token' => hash('sha256', 'phone-verify-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + session(['company' => 'company_uuid', 'storefront_key' => 'store_key']); + + $authenticate = function (array $input) { + $request = bindUnauthenticatedCustomerRequest($input); + $request->headers->set('Customer-Token', 'phone-verify-secret'); + app()->instance('request', $request); + + return $request; + }; + + // No SMS provider is bound at all — the send must not need one for a review account. + $sent = (new CustomerController())->requestPhoneVerification( + $authenticate(['phone' => '97699112233']) + ); + + expect($sent->getData(true))->toBe(['status' => 'ok', 'method' => 'bypass']) + // and nothing was queued for delivery + ->and($connection->table('verification_codes')->count())->toBe(0); + + $verified = (new CustomerController())->verifyPhoneNumber( + $authenticate(['phone' => '97699112233', 'code' => '000000']) + ); + + expect($verified)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) + ->and($connection->table('users')->where('uuid', 'user_uuid')->value('phone'))->toBe('+97699112233') + ->and($connection->table('users')->where('uuid', 'user_uuid')->value('phone_verified_at'))->not->toBeNull(); + + // A code that is not the bypass, for the same account, is still rejected. + $rejected = (new CustomerController())->verifyPhoneNumber( + $authenticate(['phone' => '97699112233', 'code' => '111111']) + ); + expect($rejected->getData(true))->toBe(['error' => 'Invalid verification code!']); + + // No phone to verify. static::phone() returns null rather than the bare '+' it used + // to, so this has to be caught before findExistingUserByPhone(string $phone) is + // reached — and long before the SMS provider is. + $noPhone = (new CustomerController())->requestPhoneVerification($authenticate([])); + expect($noPhone->getData(true))->toBe(['error' => 'A phone number is required to request verification.']); + + // A verification row that carries no meta.phone. requestPhoneVerification always + // writes one, but anything else that files a storefront_verify_phone code may not, + // and the subscript used to be unguarded — a 500 where a 400 belongs. + $connection->table('verification_codes')->insert([ + 'uuid' => 'verification_without_phone', + 'subject_uuid' => 'user_uuid', + 'subject_type' => Fleetbase\Models\User::class, + 'code' => '222222', + 'for' => 'storefront_verify_phone', + 'meta' => json_encode([]), + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $noMetaPhone = (new CustomerController())->verifyPhoneNumber( + $authenticate(['phone' => '97699112233', 'code' => '222222']) + ); + expect($noMetaPhone->getData(true))->toBe(['error' => 'Verification code is not associated with a phone number.']); + + config([ + 'storefront.storefront_app.bypass_verification_code' => null, + 'storefront.storefront_app.review_accounts' => [], + ]); +}); + test('customer password login reuses the storefront contact and issues an access token', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); @@ -1357,10 +1731,22 @@ public function clear(): void 'code' => '123456', ])); + // No identity at all. The lookup below the guard is `phone = $identity OR email = + // $identity`, which with null compiles to `phone IS NULL OR email IS NULL` and picks an + // arbitrary user to test the code against. + // + // The request has to be BOUND, not just passed: static::phone() falls back to + // request()->input('phone'), so an earlier bound request carrying a phone would supply + // an identity this call never sent. Last in the test so the rebind affects nothing else. + $withoutIdentityRequest = Request::create('/customer/code', 'POST', ['code' => '123456']); + app()->instance('request', $withoutIdentityRequest); + $withoutIdentity = (new CustomerController())->verifyCode($withoutIdentityRequest); + expect($resource)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) ->and($resource->resource->uuid)->toBe('contact_uuid') ->and($resource->resource->token)->not->toBeEmpty() ->and($invalidCode->getData(true))->toBe(['error' => 'Invalid verification code!']) + ->and($withoutIdentity->getData(true))->toBe(['error' => 'Unable to verify code.']) ->and($verified)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) ->and($verified->resource->token)->not->toBeEmpty() ->and($tokenCount)->toBe(2) @@ -1521,6 +1907,15 @@ public function clear(): void expect(CustomerController::phone())->toBe('+15551234567'); }); +test('customer phone normalization returns null when there is nothing to format', function () { + // It used to return a bare '+', which was written into contacts.phone and users.phone + // for every customer created without one, and used as a lookup key that never matched. + bindUnauthenticatedCustomerRequest(); + + expect(CustomerController::phone())->toBeNull() + ->and(CustomerController::phone(''))->toBeNull(); +}); + test('customer code verification rejects identities without a user account', function () { createCustomerControllerUsersSchema(); bindUnauthenticatedCustomerRequest(); diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php index 52dc71b4..60591b7d 100644 --- a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php @@ -23,6 +23,7 @@ function createNetworkApiControllerSchema(): void $table->text('description')->nullable(); $table->string('logo_uuid')->nullable(); $table->string('backdrop_uuid')->nullable(); + $table->boolean('online')->default(true); $table->timestamp('created_at')->nullable(); $table->timestamp('updated_at')->nullable(); $table->timestamp('deleted_at')->nullable(); @@ -58,6 +59,8 @@ function createNetworkApiControllerSchema(): void $table->increments('id'); $table->string('uuid')->nullable(); $table->string('public_id')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('for')->nullable(); $table->timestamp('deleted_at')->nullable(); }); $schema->create('files', function ($table) { @@ -113,8 +116,10 @@ function createNetworkApiControllerSchema(): void createNetworkApiControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $connection->table('categories')->insert([ - 'uuid' => 'category_uuid', - 'public_id' => 'category_abcdefgh', + 'uuid' => 'category_uuid', + 'public_id' => 'category_abcdefgh', + 'owner_uuid' => 'network_uuid', + 'for' => 'storefront_network', ]); $connection->table('networks')->insert(['uuid' => 'network_uuid']); $connection->table('stores')->insert([ @@ -201,6 +206,7 @@ function createNetworkApiControllerSchema(): void $request = Request::create('/network/stores', 'GET', [ 'category' => 'category_abcdefgh', 'tagged' => 'food,local', + 'online' => true, 'ids' => 'store_abcdefgh,store_excluded', 'exclude' => 'store_excluded', 'limit' => 10, @@ -248,8 +254,8 @@ function createNetworkApiControllerSchema(): void ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_quiet_uuid'], ]); $connection->table('checkouts')->insert([ - ['uuid' => 'checkout_one', 'store_uuid' => 'store_popular_uuid', 'created_at' => now(), 'updated_at' => now()], - ['uuid' => 'checkout_two', 'store_uuid' => 'store_popular_uuid', 'created_at' => now(), 'updated_at' => now()], + ['uuid' => 'checkout_one', 'store_uuid' => 'store_popular_uuid', 'created_at' => now()->subDays(3), 'updated_at' => now()->subDays(3)], + ['uuid' => 'checkout_two', 'store_uuid' => 'store_popular_uuid', 'created_at' => now()->subDays(2), 'updated_at' => now()->subDays(2)], ['uuid' => 'checkout_three', 'store_uuid' => 'store_quiet_uuid', 'created_at' => now(), 'updated_at' => now()], ]); session([ @@ -269,7 +275,82 @@ function createNetworkApiControllerSchema(): void 'store_popular_uuid', 'store_quiet_uuid', ])->and($resource->resource->first()->checkouts_count)->toBe(2) - ->and($trending->resource->first()->uuid)->toBe('store_popular_uuid'); + ->and($trending->resource->first()->uuid)->toBe('store_quiet_uuid') + ->and($trending->resource->first()->recent_checkouts_count)->toBe(1); +}); + +test('network stores endpoint searches member stores and includes cross-company invitees', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('stores')->insert([ + [ + 'uuid' => 'member_uuid', + 'public_id' => 'store_member', + 'company_uuid' => 'invited_company', + 'name' => 'Moonlight Bakery', + 'description' => 'Fresh sourdough', + ], + [ + 'uuid' => 'nonmember_uuid', + 'public_id' => 'store_nonmember', + 'company_uuid' => 'company_uuid', + 'name' => 'Moonlight Market', + 'description' => null, + ], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'member_location', 'store_uuid' => 'member_uuid'], + ['uuid' => 'nonmember_location', 'store_uuid' => 'nonmember_uuid'], + ]); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'member_uuid', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + + $resource = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [ + 'query' => 'sourdough', + ])); + + expect($resource->resource)->toHaveCount(1) + ->and($resource->resource->first()->uuid)->toBe('member_uuid'); +}); + +test('network stores endpoint returns no stores for an unknown or foreign category', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('categories')->insert([ + 'uuid' => 'foreign_category_uuid', + 'public_id' => 'category_foreign', + 'owner_uuid' => 'other_network_uuid', + 'for' => 'storefront_network', + ]); + $connection->table('stores')->insert([ + 'uuid' => 'member_uuid', + 'public_id' => 'store_member', + 'company_uuid' => 'company_uuid', + 'name' => 'Member store', + ]); + $connection->table('store_locations')->insert(['uuid' => 'member_location', 'store_uuid' => 'member_uuid']); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'member_uuid', + 'category_uuid' => 'foreign_category_uuid', + ]); + session(['storefront_network' => 'network_uuid', 'storefront_store' => null]); + $controller = new NetworkController(); + + $unknown = $controller->stores(Request::create('/network/stores', 'GET', ['category' => 'category_missing'])); + $foreign = $controller->stores(Request::create('/network/stores', 'GET', ['category' => 'category_foreign'])); + + expect($unknown->resource)->toBeEmpty() + ->and($foreign->resource)->toBeEmpty(); }); test('network stores endpoint honors rating and age sort contracts', function () { @@ -382,13 +463,25 @@ function createNetworkApiControllerSchema(): void 'sort' => 'nearest', 'location' => ['latitude' => 47.9184, 'longitude' => 106.9176], ])); + $limited = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'nearest', + 'location' => ['latitude' => 47.9184, 'longitude' => 106.9176], + 'limit' => 1, + 'offset' => 1, + ])); + $withinDistance = (new NetworkController())->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'newest', + 'location' => ['latitude' => 47.9184, 'longitude' => 106.9176], + 'maximum_distance' => 1000, + ])); expect($resource->resource->pluck('uuid')->all())->toBe([ 'store_near_uuid', 'store_far_uuid', ])->and($resource->resource->first()->locations->first()->distance)->toBeLessThan( $resource->resource->last()->locations->first()->distance - ); + )->and($limited->resource->pluck('uuid')->all())->toBe(['store_far_uuid']) + ->and($withinDistance->resource->pluck('uuid')->all())->toBe(['store_near_uuid']); }); test('network tags endpoint returns unique tags across assigned stores', function () { @@ -488,3 +581,35 @@ function createNetworkApiControllerSchema(): void ->and($offsetResource->resource)->toHaveCount(1) ->and($offsetResource->resource->first()->uuid)->toBe('location_other_uuid'); }); + +test('store locations and tags preserve store context while rejecting missing contexts', function () { + createNetworkApiControllerSchema(); + config(['database.connections.mysql.database' => 'main']); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert([ + ['uuid' => 'store_uuid', 'tags' => json_encode(['local', 'pickup'])], + ['uuid' => 'other_store_uuid', 'tags' => json_encode(['foreign'])], + ]); + $connection->table('places')->insert([ + ['uuid' => 'store_place_uuid'], + ['uuid' => 'other_place_uuid'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'store_location_uuid', 'public_id' => 'location_store', 'store_uuid' => 'store_uuid', 'place_uuid' => 'store_place_uuid'], + ['uuid' => 'other_location_uuid', 'public_id' => 'location_other', 'store_uuid' => 'other_store_uuid', 'place_uuid' => 'other_place_uuid'], + ]); + $controller = new NetworkController(); + session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); + + $storeLocations = $controller->storeLocations(Request::create('/network/store-locations')); + $storeTags = $controller->tags(Request::create('/network/tags')); + + session(['storefront_store' => null, 'storefront_network' => null]); + $missingLocations = $controller->storeLocations(Request::create('/network/store-locations')); + $missingTags = $controller->tags(Request::create('/network/tags')); + + expect($storeLocations->resource->pluck('uuid')->all())->toBe(['store_location_uuid']) + ->and($storeTags->getData(true))->toBe(['local', 'pickup']) + ->and($missingLocations->getStatusCode())->toBe(400) + ->and($missingTags->getStatusCode())->toBe(400); +}); diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php index 01200d36..53b61b57 100644 --- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -179,6 +179,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'store_uuid' => 'store_uuid', 'name' => 'Available', 'is_available' => true, + 'status' => 'published', ], [ 'uuid' => 'unavailable_uuid', @@ -187,6 +188,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'store_uuid' => 'store_uuid', 'name' => 'Unavailable', 'is_available' => false, + 'status' => 'published', ], [ 'uuid' => 'other_store_uuid', @@ -195,6 +197,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'store_uuid' => 'other_store', 'name' => 'Other store', 'is_available' => true, + 'status' => 'published', ], ]); session([ @@ -203,17 +206,20 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'storefront_network' => null, ]); - $resource = (new ProductController())->query(productApiRequest( + $controller = new ProductController(); + $resource = $controller->query(productApiRequest( '/products?store=store_uuid', 'GET', ['store' => 'store_uuid'] )); + $ownedUnavailable = $controller->find('product_unavailable'); expect($resource->resource)->toHaveCount(1) ->and($resource->resource->first()->uuid)->toBe('available_uuid') ->and($resource->resource->first()->relationLoaded('addonCategories'))->toBeTrue() ->and($resource->resource->first()->relationLoaded('variants'))->toBeTrue() - ->and($resource->resource->first()->relationLoaded('files'))->toBeTrue(); + ->and($resource->resource->first()->relationLoaded('files'))->toBeTrue() + ->and($ownedUnavailable->resource->uuid)->toBe('unavailable_uuid'); }); test('product query returns available products assigned through the active network', function () { @@ -240,6 +246,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'store_uuid' => 'network_store_uuid', 'name' => 'Network product', 'is_available' => true, + 'status' => 'published', ], [ 'uuid' => 'outside_product_uuid', @@ -247,6 +254,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'store_uuid' => 'outside_store_uuid', 'name' => 'Outside product', 'is_available' => true, + 'status' => 'published', ], ]); session([ @@ -297,6 +305,32 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar ->and($product->youtube_urls)->toBe(['https://example.test/demo']); }); +test('product creation defaults status to published so the product is actually readable', function () { + // The column is nullable with no default. An API-created product used to land as NULL + // and was then invisible to every `where('status', 'published')` read path — including + // CheckoutController's cart validation, which dropped it at capture time. + createProductApiControllerSchema(); + session([ + 'company' => 'company_uuid', + 'user' => 'user_uuid', + 'storefront_store' => 'store_uuid', + ]); + + $defaulted = (new ProductController())->create(CreateProductRequest::create('/products', 'POST', [ + 'name' => 'No status supplied', + 'price' => '1000', + ]))->resource; + + $explicit = (new ProductController())->create(CreateProductRequest::create('/products', 'POST', [ + 'name' => 'Draft on purpose', + 'price' => '1000', + 'status' => 'draft', + ]))->resource; + + expect($defaulted->status)->toBe('published') + ->and($explicit->status)->toBe('draft'); +}); + test('product creation persists category addons variants and option contracts', function () { createProductApiControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); @@ -354,7 +388,9 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar ], ]); - $product = (new ProductController())->create($request)->resource; + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + $product = (new ProductController())->create($request)->resource; + Model::unsetEventDispatcher(); $category = $connection->table('categories') ->where('for', 'storefront_product') ->where('name', 'Meals') @@ -443,6 +479,83 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar ->and($updated->category_uuid)->toBe($replacement->uuid); }); +test('public product query and lookup stay inside the active network membership', function () { + createProductApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert([ + 'uuid' => 'network_uuid', + 'public_id' => 'network_abcdefgh', + 'name' => 'Marketplace', + ]); + $connection->table('stores')->insert([ + [ + 'uuid' => 'member_store_uuid', + 'public_id' => 'store_member', + 'name' => 'Member store', + ], + [ + 'uuid' => 'foreign_store_uuid', + 'public_id' => 'store_foreign', + 'name' => 'Foreign store', + ], + ]); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'member_store_uuid', + ]); + $connection->table('products')->insert([ + [ + 'uuid' => 'member_product_uuid', + 'public_id' => 'product_member', + 'store_uuid' => 'member_store_uuid', + 'name' => 'Member product', + 'is_available' => 1, + 'status' => 'published', + 'meta' => '{}', + ], + [ + 'uuid' => 'foreign_product_uuid', + 'public_id' => 'product_foreign', + 'store_uuid' => 'foreign_store_uuid', + 'name' => 'Foreign product', + 'is_available' => 1, + 'status' => 'published', + 'meta' => '{}', + ], + [ + 'uuid' => 'draft_product_uuid', + 'public_id' => 'product_draft', + 'store_uuid' => 'member_store_uuid', + 'name' => 'Draft product', + 'is_available' => 1, + 'status' => 'draft', + 'meta' => '{}', + ], + ]); + session([ + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + $controller = new ProductController(); + + $memberQuery = $controller->query(productApiRequest('/products', 'GET', [ + 'store' => 'store_member', + 'with_store' => true, + ])); + $foreignQuery = $controller->query(productApiRequest('/products', 'GET', ['store' => 'store_foreign'])); + $member = $controller->find('product_member'); + $foreign = $controller->find('product_foreign'); + $draft = $controller->find('product_draft'); + + expect($memberQuery->resource)->toHaveCount(1) + ->and($memberQuery->resource->pluck('uuid')->all())->toContain('member_product_uuid') + ->and($memberQuery->resource->first()->relationLoaded('store'))->toBeTrue() + ->and($foreignQuery->resource)->toBeEmpty() + ->and($member)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Product::class) + ->and($foreign->getStatusCode())->toBe(404) + ->and($draft->getStatusCode())->toBe(404); +}); + test('product query applies a known storefront category filter', function () { createProductApiControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); @@ -462,6 +575,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'category_uuid' => 'category_uuid', 'name' => 'Drink', 'is_available' => true, + 'status' => 'published', ], [ 'uuid' => 'food_uuid', @@ -471,6 +585,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'category_uuid' => 'other_category', 'name' => 'Food', 'is_available' => true, + 'status' => 'published', ], ]); session([ @@ -528,6 +643,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'price' => 1000, 'currency' => 'USD', 'is_available' => true, + 'status' => 'published', ]); session([ 'company' => 'company_uuid', @@ -542,7 +658,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'tags' => ['updated'], 'youtube_urls' => ['https://example.test/updated'], 'category' => 'category_updated', - 'status' => 'active', + 'status' => 'published', ]); $request->setLaravelSession(new SessionStore( 'product-api-update-test', diff --git a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php index 0215e0fd..a4adbdd2 100644 --- a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php @@ -51,9 +51,9 @@ class TestableCartController extends CartController public ?Fleetbase\Storefront\Models\Cart $cart = null; public array $retrievals = []; - protected function retrieveCart(?string $uniqueId, bool $create = false): Fleetbase\Storefront\Models\Cart + protected function retrieveCart(?string $uniqueId): Fleetbase\Storefront\Models\Cart { - $this->retrievals[] = [$uniqueId, $create]; + $this->retrievals[] = [$uniqueId]; return $this->cart; } @@ -90,7 +90,13 @@ function createPublicCartControllerSchema(): void 'customer_id' => 'customer_public', ]); - $resource = (new CartController())->retrieve(null, Request::create('/cart')); + // Called with the Request ALONE, which is exactly what the router produces for + // GET /storefront/v1/carts — the route with no {uniqueId}. Laravel splices + // class-typed parameters in at their own index and fills the rest from the route + // parameters, so when the optional $uniqueId came first there was nothing for index + // 0 and the call arrived as "Too few arguments to function retrieve(), 1 passed". + // Passing both arguments explicitly, as this test used to, never exercised that. + $resource = (new CartController())->retrieve(Request::create('/cart')); $cart = $resource->resource; expect($cart->exists)->toBeTrue() @@ -106,10 +112,10 @@ function createPublicCartControllerSchema(): void session(['company' => 'company_uuid']); $controller = new CartController(); - $first = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource; - $second = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource; + $first = $controller->retrieve(Request::create('/cart'), 'browser-session-1')->resource; + $second = $controller->retrieve(Request::create('/cart'), 'browser-session-1')->resource; $first->forceFill(['checkout_uuid' => 'checkout_uuid'])->save(); - $replacement = $controller->retrieve('browser-session-1', Request::create('/cart'))->resource; + $replacement = $controller->retrieve(Request::create('/cart'), 'browser-session-1')->resource; $cartRows = Model::getConnectionResolver()->connection('mysql')->table('carts') ->where('unique_identifier', 'browser-session-1') ->get(); @@ -121,6 +127,163 @@ function createPublicCartControllerSchema(): void ->and($cartRows->whereNull('checkout_uuid'))->toHaveCount(1); }); +test('cart retrieval never reuses another company cart with the same browser identifier', function () { + createPublicCartControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('carts')->insert([ + 'uuid' => 'foreign_cart_uuid', + 'public_id' => 'cart_foreign', + 'company_uuid' => 'other_company', + 'unique_identifier' => 'shared-browser-id', + 'currency' => 'EUR', + 'items' => '[]', + 'events' => '[]', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_currency' => 'USD', + ]); + + $cart = (new CartController())->retrieve(Request::create('/cart'), 'shared-browser-id')->resource; + + expect($cart->uuid)->not->toBe('foreign_cart_uuid') + ->and($cart->company_uuid)->toBe('company_uuid') + ->and($cart->currency)->toBe('USD'); +}); + +test('cart add accepts member products and rejects products outside the active network', function () { + createPublicCartControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['files', 'store_locations', 'network_stores', 'networks', 'stores', 'products'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('logo_uuid')->nullable(); + $table->string('backdrop_uuid')->nullable(); + $table->string('name')->nullable(); + $table->boolean('online')->default(true); + $table->string('currency')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('networks', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('network_stores', function ($table) { + $table->increments('id'); + $table->string('network_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('store_locations', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('files', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('products', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('primary_image_uuid')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->integer('price')->default(0); + $table->string('currency')->nullable(); + $table->integer('sale_price')->default(0); + $table->boolean('is_on_sale')->default(false); + $table->boolean('is_available')->default(true); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('stores')->insert([ + ['uuid' => 'member_store_uuid', 'public_id' => 'store_member', 'name' => 'Member store', 'currency' => 'USD'], + ['uuid' => 'foreign_store_uuid', 'public_id' => 'store_foreign', 'name' => 'Foreign store', 'currency' => 'USD'], + ]); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'member_store_uuid', + ]); + $connection->table('store_locations')->insert([ + 'uuid' => 'member_location_uuid', + 'public_id' => 'location_member', + 'store_uuid' => 'member_store_uuid', + ]); + $connection->table('products')->insert([ + [ + 'uuid' => 'member_product_uuid', + 'public_id' => 'product_member', + 'store_uuid' => 'member_store_uuid', + 'name' => 'Member product', + 'price' => 1000, + 'currency' => 'USD', + 'is_available' => true, + 'status' => 'published', + 'meta' => '{}', + ], + [ + 'uuid' => 'foreign_product_uuid', + 'public_id' => 'product_foreign', + 'store_uuid' => 'foreign_store_uuid', + 'name' => 'Foreign product', + 'price' => 1000, + 'currency' => 'USD', + 'is_available' => true, + 'status' => 'published', + 'meta' => '{}', + ], + ]); + session([ + 'company' => 'company_uuid', + 'storefront_currency' => 'USD', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + $controller = new CartController(); + $memberRequest = Request::create('/cart/items', 'POST', [ + 'quantity' => 1, + 'store_location' => 'location_member', + ]); + + $member = $controller->add('marketplace-cart', 'product_member', $memberRequest); + $foreign = $controller->add('marketplace-cart', 'product_foreign', Request::create('/cart/items', 'POST')); + $wrongLocation = $controller->add('marketplace-cart', 'product_member', Request::create('/cart/items', 'POST', [ + 'store_location' => 'location_foreign', + ])); + $resolvedMember = $member->resolve($memberRequest); + + expect($member)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Cart::class) + ->and($member->resource->items)->toHaveCount(1) + ->and($member->resource->items[0]->store_id)->toBe('store_member') + ->and((array) data_get($resolvedMember, 'items.0.store'))->toMatchArray([ + 'id' => 'store_member', + 'name' => 'Member store', + 'online' => true, + 'currency' => 'USD', + ]) + ->and($foreign->getStatusCode())->toBe(400) + ->and($foreign->getData(true))->toHaveKey('error') + ->and($wrongLocation->getStatusCode())->toBe(400) + ->and($wrongLocation->getData(true))->toBe([ + 'error' => 'The selected store location is not available for this product.', + ]); +}); + test('cart controller reports invalid product and line item operations', function () { createPublicCartControllerSchema(); $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); @@ -178,7 +341,7 @@ function createPublicCartControllerSchema(): void $controller = new TestableCartController(); $controller->cart = $cart; - $retrieved = $controller->retrieve('browser-session', Request::create('/cart')); + $retrieved = $controller->retrieve(Request::create('/cart'), 'browser-session'); $added = $controller->add( 'browser-session', 'product_abcdefgh', @@ -214,13 +377,16 @@ function createPublicCartControllerSchema(): void ->and($removed->resource)->toBe($cart) ->and($emptied->resource)->toBe($cart) ->and($deleted->getData(true))->toBe([]) + // Every action resolves the cart the same way now. The flag used to vary here, but + // it was landing in Cart::retrieve()'s $excludeCheckedout, which meant the five + // mutating actions operated on carts that had already produced an order. ->and($controller->retrievals)->toBe([ - ['browser-session', true], - ['browser-session', false], - ['browser-session', false], - ['browser-session', false], - ['browser-session', false], - ['browser-session', false], + ['browser-session'], + ['browser-session'], + ['browser-session'], + ['browser-session'], + ['browser-session'], + ['browser-session'], ]) ->and($cart->calls['add'])->toBe([ 'product_abcdefgh', diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 99391a85..14c86195 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -3,10 +3,19 @@ use Fleetbase\Storefront\Http\Controllers\OrderController as InternalOrderController; use Fleetbase\Storefront\Http\Controllers\v1\OrderController; use Fleetbase\Storefront\Http\Controllers\v1\ReviewController; +use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Support\QPay; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; +class ReviewContextControllerStub extends ReviewController +{ + public function subjectBelongs($subject): bool + { + return $this->subjectBelongsToContext($subject); + } +} + class ReceiptQPayStub extends QPay { public object $response; @@ -276,6 +285,7 @@ function createReviewControllerSchema(): void { $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); $schema->dropIfExists('reviews'); + $schema->dropIfExists('products'); $schema->create('reviews', function ($table) { $table->increments('id'); $table->string('uuid')->nullable(); @@ -290,6 +300,13 @@ function createReviewControllerSchema(): void $table->timestamps(); $table->timestamp('deleted_at')->nullable(); }); + $schema->create('products', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); } test('review sort aliases map to stable API sort fields and directions', function (string $sort, ?array $expected) { @@ -317,12 +334,14 @@ function createReviewControllerSchema(): void ]); $controller = new ReviewController(); - $reviews = $controller->query(Request::create('/reviews')); - $counts = $controller->count(Request::create('/reviews/count')); + $reviews = $controller->query(Request::create('/reviews')); + $counts = $controller->count(Request::create('/reviews/count')); + $unsupportedSubject = (new ReviewContextControllerStub())->subjectBelongs(new stdClass()); expect($reviews->resource)->toBeEmpty() ->and($counts->getStatusCode())->toBe(200) - ->and($counts->getData(true))->toBe([]); + ->and($counts->getData(true))->toBe([]) + ->and($unsupportedSubject)->toBeFalse(); }); test('review rating counts are scoped to the active storefront store', function () { @@ -434,9 +453,8 @@ function createReviewControllerSchema(): void $table->timestamp('deleted_at')->nullable(); }); $connection->table('stores')->insert([ - 'uuid' => 'store_uuid', - 'public_id' => 'store_abcdefgh', - 'company_uuid' => 'company_uuid', + ['uuid' => 'store_uuid', 'public_id' => 'store_abcdefgh', 'company_uuid' => 'invited_company_uuid'], + ['uuid' => 'foreign_store_uuid', 'public_id' => 'store_foreign', 'company_uuid' => 'company_uuid'], ]); $connection->table('networks')->insert([ 'uuid' => 'network_uuid', @@ -464,6 +482,34 @@ function createReviewControllerSchema(): void 'created_at' => '2026-01-02 00:00:00', 'updated_at' => '2026-01-02 00:00:00', ], + [ + 'uuid' => 'foreign_network_review', + 'public_id' => 'review_network_foreign', + 'subject_uuid' => 'foreign_store_uuid', + 'rating' => 3, + 'created_at' => '2026-01-03 00:00:00', + 'updated_at' => '2026-01-03 00:00:00', + ], + [ + 'uuid' => 'network_product_review', + 'public_id' => 'review_network_product', + 'subject_uuid' => 'member_product_uuid', + 'rating' => 4, + 'created_at' => '2026-01-04 00:00:00', + 'updated_at' => '2026-01-04 00:00:00', + ], + [ + 'uuid' => 'foreign_product_review', + 'public_id' => 'review_foreign_product', + 'subject_uuid' => 'foreign_product_uuid', + 'rating' => 2, + 'created_at' => '2026-01-05 00:00:00', + 'updated_at' => '2026-01-05 00:00:00', + ], + ]); + $connection->table('products')->insert([ + ['uuid' => 'member_product_uuid', 'public_id' => 'product_member', 'store_uuid' => 'store_uuid'], + ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'], ]); session([ 'company' => 'company_uuid', @@ -491,6 +537,13 @@ function createReviewControllerSchema(): void $counts = $controller->count(Request::create('/reviews/count?store=store_abcdefgh', 'GET', [ 'store' => 'store_abcdefgh', ])); + $found = $controller->find('review_network_two'); + $foreign = $controller->find('review_network_foreign'); + $product = $controller->find('review_network_product'); + $foreignProduct = $controller->find('review_foreign_product'); + $memberProduct = Product::where('uuid', 'member_product_uuid')->firstOrFail(); + $outsideProduct = Product::where('uuid', 'foreign_product_uuid')->firstOrFail(); + $contextProbe = new ReviewContextControllerStub(); expect($missing->getStatusCode())->toBe(400) ->and($missing->getData(true))->toBe(['error' => 'Cannot find reviews for store']) @@ -498,6 +551,12 @@ function createReviewControllerSchema(): void ->and($missingCount->getData(true))->toBe(['error' => 'Cannot count reviews for store']) ->and($reviews->resource)->toHaveCount(1) ->and($reviews->resource->first()->uuid)->toBe('network_review_two') + ->and($found->resource->uuid)->toBe('network_review_two') + ->and($foreign->getStatusCode())->toBe(400) + ->and($product->resource->uuid)->toBe('network_product_review') + ->and($foreignProduct->getStatusCode())->toBe(400) + ->and($contextProbe->subjectBelongs($memberProduct))->toBeTrue() + ->and($contextProbe->subjectBelongs($outsideProduct))->toBeFalse() ->and($counts->getData(true))->toBe([ 1 => 1, 2 => 0, @@ -521,26 +580,36 @@ function createReviewControllerSchema(): void ->and($delete->getData(true))->toBe(['error' => 'Review resource not found.']); }); -test('review find and delete return and soft delete persisted review resources', function () { +test('review find is storefront scoped and unauthenticated customers cannot delete reviews', function () { createReviewControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('products')->insert([ + ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'], + ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'other_store_uuid'], + ]); $connection->table('reviews')->insert([ - 'uuid' => 'review_uuid', - 'public_id' => 'review_abcdefgh', - 'subject_uuid' => 'store_uuid', - 'rating' => 5, - 'content' => 'Excellent', - 'created_at' => now(), - 'updated_at' => now(), + ['uuid' => 'review_uuid', 'public_id' => 'review_abcdefgh', 'subject_uuid' => 'store_uuid', 'rating' => 5, 'content' => 'Excellent', 'created_at' => now(), 'updated_at' => now()], + ['uuid' => 'foreign_review_uuid', 'public_id' => 'review_foreign', 'subject_uuid' => 'other_store_uuid', 'rating' => 1, 'content' => 'Foreign', 'created_at' => now(), 'updated_at' => now()], + ['uuid' => 'product_review_uuid', 'public_id' => 'review_product', 'subject_uuid' => 'product_uuid', 'rating' => 4, 'content' => 'Great product', 'created_at' => now(), 'updated_at' => now()], + ['uuid' => 'foreign_product_review_uuid', 'public_id' => 'review_foreign_product', 'subject_uuid' => 'foreign_product_uuid', 'rating' => 2, 'content' => 'Foreign product', 'created_at' => now(), 'updated_at' => now()], ]); + session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); $controller = new ReviewController(); - $found = $controller->find('review_abcdefgh'); - $deleted = $controller->delete('review_abcdefgh'); + $found = $controller->find('review_abcdefgh'); + $foreign = $controller->find('review_foreign'); + $product = $controller->find('review_product'); + $foreignProduct = $controller->find('review_foreign_product'); + $deleted = $controller->delete('review_abcdefgh'); expect($found->resource->uuid)->toBe('review_uuid') - ->and($deleted->resource->uuid)->toBe('review_uuid') - ->and($connection->table('reviews')->where('uuid', 'review_uuid')->value('deleted_at'))->not->toBeNull(); + ->and($foreign->getStatusCode())->toBe(400) + ->and($foreign->getData(true))->toBe(['error' => 'Review resource not found.']) + ->and($product->resource->uuid)->toBe('product_review_uuid') + ->and($foreignProduct->getStatusCode())->toBe(400) + ->and($deleted->getStatusCode())->toBe(403) + ->and($deleted->getData(true))->toBe(['error' => 'Not authorized to delete review']) + ->and($connection->table('reviews')->where('uuid', 'review_uuid')->value('deleted_at'))->toBeNull(); }); test('review creation enforces customer authentication and subject validity', function () { @@ -655,6 +724,10 @@ function createReviewControllerSchema(): void 'key' => 'store_key', 'name' => 'Review store', ]); + $connection->table('products')->insert([ + ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'], + ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'], + ]); $boundRequest = Request::create('/reviews'); $boundRequest->headers->set('Customer-Token', 'review-customer-secret'); $boundRequest->setLaravelSession(new Illuminate\Session\Store( @@ -662,7 +735,12 @@ function createReviewControllerSchema(): void new Illuminate\Session\ArraySessionHandler(120) )); app()->instance('request', $boundRequest); - session(['company' => 'company_uuid', 'storefront_key' => null]); + session([ + 'company' => 'company_uuid', + 'storefront_key' => null, + 'storefront_store' => 'store_uuid', + 'storefront_network' => null, + ]); $controller = new ReviewController(); $invalid = $controller->create( @@ -678,7 +756,24 @@ function createReviewControllerSchema(): void 'content' => 'Excellent service', ]) ); + $invalidProduct = $controller->create( + Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'product_foreign', + 'rating' => 1, + ]) + ); + $createdProduct = $controller->create( + Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'product_abcdefgh', + 'rating' => 4, + 'content' => 'Excellent product', + ]) + ); $review = $connection->table('reviews')->first(); + $connection->table('reviews')->where('id', $review->id)->update([ + 'uuid' => 'owned_review_uuid', + 'public_id' => 'review_owned', + ]); Illuminate\Support\Facades\Storage::swap(new class { public function disk(string $disk): self { @@ -707,9 +802,13 @@ public function put(string $path, string $contents, string $visibility): bool ]) ); $photo = $connection->table('files')->first(); + session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); + $deleted = $controller->delete('review_owned'); expect($invalid->getData(true))->toBe(['error' => 'Invalid subject for review']) + ->and($invalidProduct->getData(true))->toBe(['error' => 'Invalid subject for review']) ->and($created->resource->uuid)->toBe($review->uuid) + ->and($createdProduct->resource->subject_uuid)->toBe('product_uuid') ->and($review->created_by_uuid)->toBe('user_uuid') ->and($review->customer_uuid)->toBe($customerUuid) ->and($review->subject_uuid)->toBe('store_uuid') @@ -720,7 +819,9 @@ public function put(string $path, string $contents, string $visibility): bool ->and($photo->content_type)->toBe('image/png') ->and($photo->bucket)->toBe('review-bucket') ->and($photo->file_size)->toBe(strlen('image-bytes')) - ->and($photo->type)->toBe('storefront_review_upload'); + ->and($photo->type)->toBe('storefront_review_upload') + ->and($deleted->resource->uuid)->toBe('owned_review_uuid') + ->and($connection->table('reviews')->where('uuid', 'owned_review_uuid')->value('deleted_at'))->not->toBeNull(); }); test('customer order actions require a customer token before order lookup', function () { diff --git a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php index 00abe373..c967b641 100644 --- a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php @@ -140,7 +140,7 @@ function createServiceQuoteLookupSchema(): void $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['places', 'store_locations', 'stores', 'vehicles', 'food_trucks', 'products', 'files', 'carts', 'service_quote_items', 'service_quotes', 'service_rates', 'integrated_vendors'] as $table) { + foreach (['network_stores', 'networks', 'places', 'store_locations', 'stores', 'vehicles', 'food_trucks', 'products', 'files', 'carts', 'service_quote_items', 'service_quotes', 'service_rates', 'integrated_vendors'] as $table) { $schema->dropIfExists($table); } @@ -170,6 +170,22 @@ function createServiceQuoteLookupSchema(): void $table->text('options')->nullable(); $table->timestamp('deleted_at')->nullable(); }); + $schema->create('networks', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('network_stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('network_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); $schema->create('vehicles', function ($table) { $table->increments('id'); $table->string('uuid')->nullable(); @@ -265,6 +281,26 @@ function createServiceQuoteLookupSchema(): void }); } +function seedServiceQuoteMarketplace(string $storeUuid = 'store_uuid', string $storePublicId = 'store_public'): void +{ + $connection = Model::getConnectionResolver()->connection('mysql'); + + $connection->table('stores')->updateOrInsert( + ['uuid' => $storeUuid], + ['public_id' => $storePublicId, 'name' => 'Marketplace Store', 'options' => '{}'] + ); + $connection->table('networks')->updateOrInsert( + ['uuid' => 'network_uuid'], + ['public_id' => 'network_public', 'name' => 'Marketplace Network'] + ); + $connection->table('network_stores')->updateOrInsert( + ['network_uuid' => 'network_uuid', 'store_uuid' => $storeUuid], + ['uuid' => 'network_store_' . $storeUuid, 'created_at' => now(), 'updated_at' => now()] + ); + + session(['storefront_network' => 'network_uuid']); +} + test('service quote place lookup resolves tenant places and rejects missing typed resources', function () { createServiceQuoteLookupSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); @@ -467,6 +503,17 @@ public function get(): Illuminate\Support\Collection test('network service quote rejects missing integrated facilitators safely', function () { createServiceQuoteLookupSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); + seedServiceQuoteMarketplace(); + $connection->table('places')->insert([ + 'uuid' => 'origin_uuid', + 'public_id' => 'place_origin', + ]); + $connection->table('store_locations')->insert([ + 'uuid' => 'location_uuid', + 'public_id' => 'store_location_public', + 'store_uuid' => 'store_uuid', + 'place_uuid' => 'origin_uuid', + ]); $connection->table('carts')->insert([ 'uuid' => 'cart_uuid', 'public_id' => 'cart_public', @@ -495,6 +542,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla $missingFacilitator = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [ 'destination' => 'place_destination', 'cart' => 'network-cart', + 'origin' => 'store_location_public', 'facilitator' => 'integrated_vendor_missing', ])); @@ -514,6 +562,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'name' => 'Corner Store', 'options' => '{}', ]); + seedServiceQuoteMarketplace(); $connection->table('store_locations')->insert([ 'uuid' => 'location_uuid', 'public_id' => 'store_location_public', @@ -524,6 +573,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla [ 'uuid' => 'explicit_cart_uuid', 'public_id' => 'explicit_cart_public', + 'company_uuid' => 'company_uuid', 'unique_identifier' => 'explicit-cart', 'currency' => 'USD', 'items' => json_encode([ @@ -540,6 +590,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla [ 'uuid' => 'default_cart_uuid', 'public_id' => 'default_cart_public', + 'company_uuid' => 'company_uuid', 'unique_identifier' => 'default-cart', 'currency' => 'USD', 'items' => json_encode([ @@ -553,11 +604,56 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'created_at' => now(), 'updated_at' => now(), ], + [ + 'uuid' => 'missing_location_cart_uuid', + 'public_id' => 'missing_location_cart_public', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'missing-location-cart', + 'currency' => 'USD', + 'items' => json_encode([ + [ + 'store_id' => 'store_public', + 'store_location_id' => 'store_location_missing', + ], + ]), + 'events' => '[]', + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'uuid' => 'mismatched_location_cart_uuid', + 'public_id' => 'mismatched_location_cart_public', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'mismatched-location-cart', + 'currency' => 'USD', + 'items' => json_encode([ + [ + 'store_id' => 'store_different', + 'store_location_id' => 'store_location_public', + ], + ]), + 'events' => '[]', + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ], ]); session([ 'company' => 'company_uuid', 'storefront_key' => 'network_public', ]); + $scopedLocation = Fleetbase\Storefront\Models\StoreLocation::where('public_id', 'store_location_public') + ->whereHas('store.networks', fn ($query) => $query->where('network_uuid', session('storefront_network'))) + ->with('store') + ->first(); + $scopedStore = Fleetbase\Storefront\Models\Store::where('public_id', 'store_public') + ->whereHas('networks', fn ($query) => $query->where('network_uuid', session('storefront_network'))) + ->with('locations') + ->first(); + expect(session('storefront_network'))->toBe('network_uuid') + ->and($scopedLocation?->store?->public_id)->toBe('store_public') + ->and(data_get($scopedStore, 'locations.0.public_id'))->toBe('store_location_public'); $controller = new class extends ServiceQuoteController { public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Place { @@ -578,14 +674,31 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'cart' => 'default-cart', 'facilitator' => 'integrated_vendor_missing', ])); + $missingLocation = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [ + 'destination' => 'place_destination', + 'cart' => 'missing-location-cart', + ])); + $mismatchedLocation = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [ + 'destination' => 'place_destination', + 'cart' => 'mismatched-location-cart', + ])); - expect($explicit->getData(true))->toBe(['error' => 'Integrated vendor not found!']) - ->and($default->getData(true))->toBe(['error' => 'Integrated vendor not found!']); + expect($default->getData(true))->toBe(['error' => 'Integrated vendor not found!']) + ->and($explicit->getData(true))->toBe(['error' => 'Integrated vendor not found!']) + ->and($missingLocation->getStatusCode())->toBe(422) + ->and($missingLocation->getData(true))->toBe([ + 'error' => 'One or more store locations are unavailable for this marketplace.', + ]) + ->and($mismatchedLocation->getStatusCode())->toBe(422) + ->and($mismatchedLocation->getData(true))->toBe([ + 'error' => 'One or more store locations are unavailable for this marketplace.', + ]); }); test('network service quote persists integrated facilitator origin metadata and provider errors', function () { createServiceQuoteLookupSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); + seedServiceQuoteMarketplace(); $connection->table('places')->insert([ 'uuid' => 'origin_uuid', 'public_id' => 'place_origin', @@ -593,11 +706,13 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla $connection->table('store_locations')->insert([ 'uuid' => 'location_uuid', 'public_id' => 'store_location_public', + 'store_uuid' => 'store_uuid', 'place_uuid' => 'origin_uuid', ]); $connection->table('carts')->insert([ 'uuid' => 'cart_uuid', 'public_id' => 'cart_public', + 'company_uuid' => 'company_uuid', 'unique_identifier' => 'network-cart', 'currency' => 'USD', 'items' => json_encode([ @@ -854,6 +969,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla test('network service quote resolves comma separated fallback origins before reporting no rates', function () { createServiceQuoteLookupSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); + seedServiceQuoteMarketplace(); $connection->table('places')->insert([ ['uuid' => 'place_one_uuid', 'public_id' => 'place_one'], ['uuid' => 'place_two_uuid', 'public_id' => 'place_two'], @@ -862,11 +978,13 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla [ 'uuid' => 'location_one_uuid', 'public_id' => 'store_location_one', + 'store_uuid' => 'store_uuid', 'place_uuid' => 'place_one_uuid', ], [ 'uuid' => 'location_two_uuid', 'public_id' => 'store_location_two', + 'store_uuid' => 'store_uuid', 'place_uuid' => 'place_two_uuid', ], ]); @@ -909,6 +1027,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla test('network service quote persists local rate lines and selects the lowest matching quote', function () { createServiceQuoteLookupSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); + seedServiceQuoteMarketplace(); $connection->table('products')->insert([ 'uuid' => 'product_uuid', 'public_id' => 'product_public', @@ -927,6 +1046,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla $connection->table('store_locations')->insert([ 'uuid' => 'location_uuid', 'public_id' => 'store_location_origin', + 'store_uuid' => 'store_uuid', 'place_uuid' => 'origin_uuid', ]); $connection->table('carts')->insert([ @@ -991,6 +1111,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla test('network service quote falls back to an integrated provider when local rates are unavailable', function () { createServiceQuoteLookupSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); + seedServiceQuoteMarketplace(); $connection->table('places')->insert([ 'uuid' => 'origin_uuid', 'public_id' => 'place_origin', @@ -998,6 +1119,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla $connection->table('store_locations')->insert([ 'uuid' => 'location_uuid', 'public_id' => 'store_location_origin', + 'store_uuid' => 'store_uuid', 'place_uuid' => 'origin_uuid', ]); $connection->table('carts')->insert([ diff --git a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php index a987489e..3bdbd16d 100644 --- a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php @@ -506,6 +506,148 @@ public function sendNow($notifiables, $notification, ?array $channels = null) ->and($location->getData(true))->toBe(['error' => 'Networks cannot have locations!']); }); +test('network storefront lookup and location access are limited to member stores including cross-company invitees', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['store_hours', 'places', 'store_locations', 'network_stores', 'networks', 'stores'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('networks', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('network_stores', function ($table) { + $table->increments('id'); + $table->string('network_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('category_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('places', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('store_locations', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('place_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('store_hours', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('store_location_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('networks')->insert([ + 'uuid' => 'network_uuid', + 'public_id' => 'network_public', + 'company_uuid' => 'network_company', + ]); + $connection->table('stores')->insert([ + [ + 'uuid' => 'member_store_uuid', + 'public_id' => 'store_member', + 'company_uuid' => 'invited_company', + ], + [ + 'uuid' => 'foreign_store_uuid', + 'public_id' => 'store_foreign', + 'company_uuid' => 'network_company', + ], + ]); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'member_store_uuid', + ]); + $connection->table('places')->insert([ + ['uuid' => 'member_place_uuid', 'public_id' => 'place_member'], + ['uuid' => 'foreign_place_uuid', 'public_id' => 'place_foreign'], + ]); + $connection->table('store_locations')->insert([ + [ + 'uuid' => 'member_location_uuid', + 'public_id' => 'location_member', + 'store_uuid' => 'member_store_uuid', + 'place_uuid' => 'member_place_uuid', + ], + [ + 'uuid' => 'foreign_location_uuid', + 'public_id' => 'location_foreign', + 'store_uuid' => 'foreign_store_uuid', + 'place_uuid' => 'foreign_place_uuid', + ], + ]); + session([ + 'company' => 'network_company', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + $controller = new StoreController(); + + $memberLookup = $controller->lookup('store_member'); + $foreignLookup = $controller->lookup('store_foreign'); + $memberLocations = $controller->locations(Request::create('/locations', 'GET', ['store' => 'store_member'])); + $foreignLocations = $controller->locations(Request::create('/locations', 'GET', ['store' => 'store_foreign'])); + $foreignLocation = $controller->location('location_foreign', Request::create('/locations/location_foreign', 'GET', ['store' => 'store_foreign'])); + + expect($memberLookup)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Store::class) + ->and($foreignLookup->getStatusCode())->toBe(400) + ->and($memberLocations->resource)->toHaveCount(1) + ->and($foreignLocations->resource)->toBeEmpty() + ->and($foreignLocation->getStatusCode())->toBe(404); +}); + +test('store controller answers 404 for an unknown store or location instead of throwing', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['store_hours', 'store_locations', 'places', 'stores'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('stores', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id'); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('store_locations', function ($table) { + $table->string('uuid')->primary(); + $table->string('public_id'); + $table->string('store_uuid'); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_public']); + + session(['storefront_network' => null, 'storefront_store' => 'store_public']); + $request = Request::create('/v1/storefront/locations'); + + // Both lookups were unguarded, so an id that resolved nothing reached the resource + // and threw inside it — the endpoint answered 500 with an HTML stack trace for any + // stale or mistyped location id. + $missingLocation = (new StoreController())->location('location_does_not_exist', $request); + + session(['storefront_store' => 'store_does_not_exist']); + $missingStore = (new StoreController())->location('location_public', $request); + + expect($missingLocation->getStatusCode())->toBe(404) + ->and($missingLocation->getData(true))->toBe(['error' => 'Store location not found!']) + ->and($missingStore->getStatusCode())->toBe(404) + ->and($missingStore->getData(true))->toBe(['error' => 'Unable to find store!']); +}); + test('store controller resolves active and explicitly selected store locations with their relations', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); @@ -706,9 +848,10 @@ public function sendNow($notifiables, $notification, ?array $channels = null) 'storefront_network' => 'network_uuid', ]); $networkResults = $controller->search(Request::create('/search', 'GET', [ - 'query' => 'Coffee', - 'store' => 'store_abcdefgh', - 'limit' => 10, + 'query' => 'Coffee', + 'store' => 'store_abcdefgh', + 'limit' => 10, + 'with_store' => true, ])); expect($storeResults->resource->pluck('uuid')->all())->toBe([ @@ -716,7 +859,7 @@ public function sendNow($notifiables, $notification, ?array $channels = null) 'category_product_uuid', ])->and($networkResults->resource->pluck('uuid')->all())->toBe([ 'direct_product_uuid', - ]); + ])->and($networkResults->resource->first()->relationLoaded('store'))->toBeTrue(); }); test('network controller resolves public IDs and invitation codes to their network', function () { diff --git a/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php b/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php index c63951a2..70740282 100644 --- a/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php +++ b/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php @@ -93,6 +93,58 @@ ->and(session('api_credential'))->toBe('network_secret'); }); +test('switching between a network and a store key never leaves both scopes in the session', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['stores', 'networks'] as $table) { + $schema->dropIfExists($table); + $schema->create($table, function ($blueprint) { + $blueprint->string('uuid')->primary(); + $blueprint->string('public_id'); + $blueprint->string('company_uuid'); + $blueprint->string('key'); + $blueprint->string('currency'); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('stores')->insert([ + 'uuid' => 'store_uuid', + 'public_id' => 'store_public', + 'company_uuid' => 'company_uuid', + 'key' => 'store_secret', + 'currency' => 'USD', + ]); + $connection->table('networks')->insert([ + 'uuid' => 'network_uuid', + 'public_id' => 'network_public', + 'company_uuid' => 'company_uuid', + 'key' => 'network_secret', + 'currency' => 'MNT', + ]); + + $middleware = new SetStorefrontSession(); + + // The session is cookie-backed, so a client hitting a network endpoint and then a + // store endpoint reuses it. Leaving both scopes set made every store-scoped query + // also apply the stricter network filter. + $middleware->setKey('network_secret'); + $middleware->setKey('store_secret'); + + expect(session('storefront_store'))->toBe('store_uuid') + ->and(session('storefront_store_public_id'))->toBe('store_public') + ->and(session('storefront_network'))->toBeNull() + ->and(session('storefront_network_public_id'))->toBeNull(); + + $middleware->setKey('network_secret'); + + expect(session('storefront_network'))->toBe('network_uuid') + ->and(session('storefront_network_public_id'))->toBe('network_public') + ->and(session('storefront_store'))->toBeNull() + ->and(session('storefront_store_public_id'))->toBeNull(); +}); + test('customer setup is a no-op without a customer token', function () { $middleware = new SetStorefrontSession(); diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index 160cbeb8..d8840dd9 100644 --- a/server/tests/Unit/Http/Requests/RequestContractsTest.php +++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php @@ -67,12 +67,26 @@ $deliveryRules = $deliveryRequest->rules(); $pickupRules = $pickupRequest->rules(); - expect($deliveryRules)->toHaveKeys(['gateway', 'customer', 'cart', 'serviceQuote', 'cash', 'pickup']) + expect($deliveryRules)->toHaveKeys(['gateway', 'customer', 'cart', 'serviceQuote', 'service_quote', 'cash', 'pickup']) ->and($deliveryRules['gateway'][1])->toBeInstanceOf(GatewayExists::class) ->and($deliveryRules['customer'][1])->toBeInstanceOf(CustomerExists::class) ->and($deliveryRules['serviceQuote'][0])->toBeInstanceOf(RequiredIf::class) ->and((string) $deliveryRules['serviceQuote'][0])->toBe('required') - ->and((string) $pickupRules['serviceQuote'][0])->toBe(''); + ->and((string) $deliveryRules['service_quote'][0])->toBe('required') + ->and((string) $pickupRules['serviceQuote'][0])->toBe('') + ->and((string) $pickupRules['service_quote'][0])->toBe(''); +}); + +test('either spelling of the service quote satisfies the delivery requirement', function () { + // The controller reads or(['serviceQuote', 'service_quote']), so validating only one spelling + // rejected the other before the controller ran. + $snakeRules = InitializeCheckoutRequest::create('/checkout', 'POST', ['pickup' => false, 'service_quote' => 'quote_1'])->rules(); + $camelRules = InitializeCheckoutRequest::create('/checkout', 'POST', ['pickup' => false, 'serviceQuote' => 'quote_1'])->rules(); + + expect((string) $snakeRules['serviceQuote'][0])->toBe('') + ->and((string) $snakeRules['service_quote'][0])->toBe('required') + ->and((string) $camelRules['service_quote'][0])->toBe('') + ->and((string) $camelRules['serviceQuote'][0])->toBe('required'); }); test('service quote request varies origin validation by storefront key type', function () { @@ -117,7 +131,11 @@ ])->and($createRules['price'][0])->toBeInstanceOf(RequiredIf::class) ->and((string) $createRules['price'][0])->toBe('required') ->and((string) $updateRules['price'][0])->toBe('') - ->and($createRules['status'])->toContain('in:draft,active,archived') + // `published` is what the eleven read paths filter on and what the console writes. + // Leaving it out meant a product created through the public API could never be + // seen by a network storefront, nor survive CheckoutController's cart validation. + ->and($createRules['status'])->toContain('in:draft,active,archived,published') + ->and($updateRules['status'])->toContain('in:draft,active,archived,published') ->and($createRules['currency'])->toContain('size:3'); }); @@ -131,7 +149,11 @@ expect($customerRules)->toHaveKeys(['code', 'name', 'email', 'phone']) ->and($customerRules['code'])->toBe('required|exists:verification_codes,code') + // `subject` is required: the controller resolves it with Utils::resolveSubject(), + // whose parameter is a non-nullable string, so omitting it threw a TypeError as a + // 500 before the controller's own "Invalid subject for review" guard could run. ->and($reviewRules)->toBe([ + 'subject' => 'required|string', 'rating' => 'required|numeric', 'content' => 'required', 'files' => 'sometimes|array', diff --git a/server/tests/Unit/Models/CartTest.php b/server/tests/Unit/Models/CartTest.php index e3c506e6..da61660e 100644 --- a/server/tests/Unit/Models/CartTest.php +++ b/server/tests/Unit/Models/CartTest.php @@ -21,6 +21,7 @@ public static function findProduct(string $id): ?Product function createCartLifecycleSchema(): void { $schema = Capsule::schema('mysql'); + $schema->dropIfExists('store_locations'); $schema->dropIfExists('stores'); $schema->dropIfExists('carts'); $schema->create('stores', function (Blueprint $table) { @@ -30,6 +31,14 @@ function createCartLifecycleSchema(): void $table->timestamps(); $table->softDeletes(); }); + $schema->create('store_locations', function (Blueprint $table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid'); + $table->timestamps(); + $table->softDeletes(); + }); $schema->create('carts', function (Blueprint $table) { $table->increments('id'); $table->string('uuid')->nullable(); @@ -228,6 +237,13 @@ function cartWithItems(): Cart 'created_at' => now(), 'updated_at' => now(), ]); + Capsule::connection('mysql')->table('store_locations')->insert([ + 'uuid' => 'location_uuid', + 'public_id' => 'location_public', + 'store_uuid' => 'store_uuid', + 'created_at' => now(), + 'updated_at' => now(), + ]); $store = new Store(); $store->forceFill(['public_id' => 'store_public']); diff --git a/server/tests/Unit/Notifications/NotificationContractsTest.php b/server/tests/Unit/Notifications/NotificationContractsTest.php index d39f54b5..1c9db676 100644 --- a/server/tests/Unit/Notifications/NotificationContractsTest.php +++ b/server/tests/Unit/Notifications/NotificationContractsTest.php @@ -250,8 +250,11 @@ function notificationOrder(array $meta = []): Order 'currency' => 'USD', ]); $order->setRelation('payload', (object) [ - 'entities' => collect([(object) ['name' => 'Coffee'], (object) ['name' => 'Cake']]), - 'dropoff' => (object) ['address' => '1 Market Street'], + 'entities' => collect([(object) ['name' => 'Coffee'], (object) ['name' => 'Cake']]), + 'dropoff' => (object) ['address' => '1 Market Street'], + 'pickup_name' => null, + 'dropoff_name' => null, + 'return_name' => null, ]); $store = new Store(['name' => 'Corner Store']); $notification = notificationWithoutConstructor(StorefrontOrderCreated::class, $order, $store); diff --git a/server/tests/Unit/Support/QPayTest.php b/server/tests/Unit/Support/QPayTest.php index 17c59f5c..d4d15d16 100644 --- a/server/tests/Unit/Support/QPayTest.php +++ b/server/tests/Unit/Support/QPayTest.php @@ -260,7 +260,10 @@ function qpayWithResponses(array $responses, array &$history): QPay ]), ]); $item = (object) ['product_id' => 'product_abcdefgh']; - $stringMetaItem = (object) ['meta' => json_encode(['tax_product_code' => '202'])]; + $stringMetaItem = (object) [ + 'meta' => json_encode(['tax_product_code' => '202']), + 'product_id' => null, + ]; expect(QPay::getCartItemClassificationCode($item))->toBe('2111500') ->and(QPay::getCartItemTaxProductCode($item))->toBe('201') diff --git a/server/tests/Unit/Support/StorefrontTest.php b/server/tests/Unit/Support/StorefrontTest.php index 8c5cc7b7..5c58d10f 100644 --- a/server/tests/Unit/Support/StorefrontTest.php +++ b/server/tests/Unit/Support/StorefrontTest.php @@ -227,6 +227,7 @@ function createStorefrontSupportSchema(): void ]); $connection->table('order_configs')->insert([ 'uuid' => 'order_config_uuid', + 'flow' => '[]', 'activities' => '[]', 'created_at' => now(), 'updated_at' => now(), @@ -278,6 +279,7 @@ function createStorefrontSupportSchema(): void ]); $connection->table('order_configs')->insert([ 'uuid' => 'order_config_uuid', + 'flow' => '[]', 'activities' => '[]', 'created_at' => now(), 'updated_at' => now(), @@ -351,13 +353,14 @@ function createStorefrontSupportSchema(): void 'name' => 'Corner Store', ]); $connection->table('order_configs')->insert([ - 'uuid' => 'order_config_uuid', - 'company_uuid' => '11111111-1111-4111-8111-111111111111', - 'key' => 'storefront-config', - 'namespace' => 'storefront', - 'activities' => '[]', - 'created_at' => now(), - 'updated_at' => now(), + 'uuid' => 'order_config_uuid', + 'company_uuid' => '11111111-1111-4111-8111-111111111111', + 'key' => 'storefront-config', + 'namespace' => 'storefront', + 'flow' => '[]', + 'activities' => '[]', + 'created_at' => now(), + 'updated_at' => now(), ]); session([ 'company' => '11111111-1111-4111-8111-111111111111', @@ -374,13 +377,14 @@ function createStorefrontSupportSchema(): void $company = new Fleetbase\Models\Company(); $company->uuid = '22222222-2222-4222-8222-222222222222'; $connection->table('order_configs')->insert([ - 'uuid' => 'patch_config_uuid', - 'company_uuid' => '33333333-3333-4333-8333-333333333333', - 'key' => 'storefront', - 'namespace' => 'system:order-config:storefront', - 'activities' => '[]', - 'created_at' => now(), - 'updated_at' => now(), + 'uuid' => 'patch_config_uuid', + 'company_uuid' => '33333333-3333-4333-8333-333333333333', + 'key' => 'storefront', + 'namespace' => 'system:order-config:storefront', + 'flow' => '[]', + 'activities' => '[]', + 'created_at' => now(), + 'updated_at' => now(), ]); $companyOrder = new StorefrontOrderStub(); $companyOrder->forceFill(['company_uuid' => '33333333-3333-4333-8333-333333333333']);