From 5ce8ad3dfd9b86e94d291a4dcc4e6c29f6f7db36 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 8 Aug 2026 00:00:04 +0800 Subject: [PATCH 01/26] ci: point the Postman contract at the v0.7.53 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract job pinned the reusable workflow to @dev-v0.7.53, a pre-release branch. That branch is now merged (fleetbase/fleetbase#575) and v0.7.53 is tagged, with fleetbase/fleetbase-api:v0.7.53 published to Docker Hub. - pins the reusable workflow to @v0.7.53 instead of the dev branch, so runs are reproducible rather than tracking a branch that can move or be deleted - passes fleetbase-ref: v0.7.53 explicitly. The reusable workflow still defaults that input to dev-v0.7.53, so without this the job would boot the stack from the pre-release branch while testing against the released image. Passing it makes the booted source and the published image the same commit. Bump both refs together at each release. Contract runs on this repo were previously failing before they reached Postman — the installer step died building the console image, because console/package.json and console/pnpm-lock.yaml were briefly out of sync on the release branch and console/Dockerfile installs with --frozen-lockfile. That is fixed in v0.7.53. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index e7ab6fb..1129506 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -4,7 +4,9 @@ name: API Contract (Postman) # 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. +# Pinned to the v0.7.53 release tag: that is the commit fleetbase/fleetbase-api:v0.7.53 +# was built from, so the booted stack and the published image match. Bump both refs +# together at each release. on: push: @@ -18,8 +20,9 @@ permissions: jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@v0.7.53 with: collections: "Fleetbase Storefront API" build-from-source: false + fleetbase-ref: v0.7.53 secrets: inherit From 82b4aa73bcd24734cb7f66247fcb76a42942e5d0 Mon Sep 17 00:00:00 2001 From: Ron Date: Sat, 8 Aug 2026 12:11:49 +0800 Subject: [PATCH 02/26] ci: unpin the contract workflow now that it tracks latest fleetbase/fleetbase#578 changed the reusable workflow to default fleetbase-ref to main and to test against fleetbase/fleetbase-api:latest, so there is no longer a per-release ref to bump here. Drops the explicit fleetbase-ref and moves the workflow reference from @v0.7.53 to @main. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 1129506..0dd033f 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -1,12 +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. -# Pinned to the v0.7.53 release tag: that is the commit fleetbase/fleetbase-api:v0.7.53 -# was built from, so the booted stack and the published image match. Bump both refs -# together at each release. +# +# 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: @@ -20,9 +26,8 @@ permissions: jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@v0.7.53 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main with: collections: "Fleetbase Storefront API" build-from-source: false - fleetbase-ref: v0.7.53 secrets: inherit From f8b527b519125b82284529e05fb60731d9958202 Mon Sep 17 00:00:00 2001 From: Ron Date: Mon, 10 Aug 2026 15:00:04 +0800 Subject: [PATCH 03/26] ci(postman): test this branch's API code, not the published package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With build-from-source: false the stack boots the published API image, and this package is a composer dependency baked into it — so a PR here booted the released version and ran the collections against that. Its own API changes were never exercised; the check was green on code that was not under review. overlay-package makes the reusable workflow check this repository out at the commit under test and swap it into the running container, dumping the autoloader (the image is built with --optimize-autoloader, so a frozen classmap would otherwise hide classes added or moved on the branch), clearing caches and running migrations. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 0dd033f..3d3264f 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -30,4 +30,8 @@ jobs: 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 From e06d18267aabe816c8dcbd6b28a12386b8094829 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 15:02:20 +0800 Subject: [PATCH 04/26] chore(release): v0.4.19 Version bump via `flb version-bump --patch`, opening the release branch for this cycle. Follows the standard Fleetbase flow of release branch -> main. Co-Authored-By: Claude Opus 5 --- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 353c566..b2543c0 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 0bcc175..b14d893 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 c778932..164e3eb 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", From 6f4b97a1b020ae3d69d1fbcc57657a38085ede0a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 18:36:17 +0800 Subject: [PATCH 05/26] fix(auth): scope the verification bypass to designated review accounts STOREFRONT_BYPASS_VERIFICATION_CODE defaulted to 999000 and was compared against the submitted code alone, so every install shipped a live bypass that authenticated ANY customer to anyone who knew six digits. Scopes it to explicitly designated accounts. A code is only honoured for an identity listed in the new review_accounts config, so app store reviewers keep working in production while a leaked code authenticates nobody. STOREFRONT_BYPASS_VERIFICATION_CODE= STOREFRONT_REVIEW_ACCOUNTS=apple-review@example.com,+15555550100 Both values are required and the 999000 default is removed, so an unconfigured install has no bypass at all. Comparison is constant-time via hash_equals, and each accepted bypass is logged with its identity so use is auditable. Applies to both call sites: verifyCode and confirmAccountClosure. BREAKING: an install relying on the 999000 default must now set both STOREFRONT_BYPASS_VERIFICATION_CODE and STOREFRONT_REVIEW_ACCOUNTS, or app review logins will stop working. Co-Authored-By: Claude Opus 5 --- server/config/storefront.php | 19 +++++++- .../Controllers/v1/CustomerController.php | 45 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/server/config/storefront.php b/server/config/storefront.php index 9312c57..7161a4c 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/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 3a38bac..212b422 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -22,6 +22,7 @@ use Fleetbase\Storefront\Http\Resources\Customer; use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; +use Illuminate\Support\Facades\Log; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -723,6 +724,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); @@ -743,7 +784,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!'); } @@ -949,7 +990,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!'); } From c7c2ba211488b5fad5fc3bc1495f7e2a150fb0de Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 19:04:51 +0800 Subject: [PATCH 06/26] fix(checkout): verify the customer against the authenticated session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkout took the customer from the request body and trusted it: $customerId = $request->input('customer'); $customer = Customer::findFromCustomerId($customerId); 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 by passing their id. Neither credential the request carries was consulted when resolving who the checkout was for. When a Customer-Token is present it now wins, and a body parameter naming a different customer is refused with 403 rather than silently honoured. Applied at all three sites: beforeCheckout, createStripeSetupIntentForCustomer and updateStripePaymentIntent. Guest checkout is deliberately preserved. With no token the body parameter is still used, because a guest has none to present — so this closes the escalation without removing the anonymous flow. The comparison normalises the prefix first. A storefront customer is stored as a Contact and findFromCustomerId() rewrites customer_ to contact_ before looking it up, so comparing the raw values would never match a caller's own record and would have 403'd every authenticated checkout. Verified against the committed logic: a caller's own customer_ id resolves against their contact_ record, a contact_ id passed directly resolves, someone else's id is refused, an absent body parameter falls back to the token, and both guest paths still resolve from the body. Co-Authored-By: Claude Opus 5 --- .../Controllers/v1/CheckoutController.php | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 375f96a..b5fc318 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -163,6 +163,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 \Fleetbase\Storefront\Models\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'); @@ -185,7 +226,10 @@ public function beforeCheckout(InitializeCheckoutRequest $request) // find and validate cart session $cart = Cart::retrieve($cartId); $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 @@ -373,7 +417,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 +519,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'); } From 14b913cf8c8c3a1441b0e5ad94247ed889d0761e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 11 Aug 2026 15:45:18 +0800 Subject: [PATCH 07/26] fix(api): GET /storefront/v1/carts crashed on an argument order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CartController::retrieve() declared its optional $uniqueId BEFORE the injected Request. Laravel resolves method dependencies by splicing class-typed parameters in at their own index and filling the remainder from the route parameters, in order — so for the route with no {uniqueId} there was nothing to place at index 0, the Request landed at index 1, and the call arrived with a hole: ArgumentCountError: Too few arguments to function retrieve(), 1 passed GET /storefront/v1/carts/{uniqueId} worked, because index 0 was filled. That is why this read as a cart problem rather than a signature one. Five requests in the Storefront collection failed behind it — the whole cart chain depends on retrieving a cart first. The unit tests never caught it because every call site passed both arguments explicitly, matching the broken signature rather than what the router produces. The no-identifier test now calls retrieve() with the Request alone, which is exactly the failing shape. Suite passes. Co-Authored-By: Claude Opus 5 --- .../src/Http/Controllers/v1/CartController.php | 13 ++++++++++++- .../PublicCommerceControllerContractsTest.php | 16 +++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php index c1f00af..dcffded 100644 --- a/server/src/Http/Controllers/v1/CartController.php +++ b/server/src/Http/Controllers/v1/CartController.php @@ -18,9 +18,20 @@ protected function retrieveCart(?string $uniqueId, bool $create = false): Cart * 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); diff --git a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php index 0215e0f..02618c9 100644 --- a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php @@ -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(); @@ -178,7 +184,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', From 0573b61eac811b193d60a0ebfecee40b504c0766 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 11 Aug 2026 16:10:24 +0800 Subject: [PATCH 08/26] fix(api): an unknown store location answered 500 instead of 404 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StoreController::location() used both lookups unguarded — Store::first() and StoreLocation::first() — and handed the result straight to StoreLocationResource. An id that resolved nothing therefore threw inside the resource: GET /storefront/v1/locations/{id} 500 Call to a member function first() on null (22 KB of HTML) That is what a client sees for any stale or mistyped location id, and what the contract run hit. Both cases now answer 404 with a JSON error, matching how the rest of the controller reports a missing store. Co-Authored-By: Claude Opus 5 --- .../Http/Controllers/v1/StoreController.php | 12 +++++++ .../SmallControllerContractsTest.php | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/server/src/Http/Controllers/v1/StoreController.php b/server/src/Http/Controllers/v1/StoreController.php index 9c47e34..303356a 100644 --- a/server/src/Http/Controllers/v1/StoreController.php +++ b/server/src/Http/Controllers/v1/StoreController.php @@ -100,6 +100,14 @@ public function location(string $id, Request $request) $storeId = $request->input('store', session('storefront_store')); $store = Store::where('public_id', $storeId)->orWhere('uuid', $storeId)->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, 'store_uuid' => $store->uuid, @@ -107,6 +115,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); } diff --git a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php index a987489..fb517bc 100644 --- a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php @@ -506,6 +506,42 @@ public function sendNow($notifiables, $notification, ?array $channels = null) ->and($location->getData(true))->toBe(['error' => 'Networks cannot have locations!']); }); +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(); From a533a1ca589a32900cceb6080c9a9167bde64196 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 11 Aug 2026 16:28:24 +0800 Subject: [PATCH 09/26] fix(api): a malformed Apple identity token answered 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loginWithApple() verifies the token inside a try block whose catch returns response()->apiError($e->getMessage(), 500). The JWT parser THROWS on a malformed token rather than returning false, so any client sending a truncated, corrupted or otherwise unparseable identityToken got: 500 {"error":"The JWT string must have two dots"} which is both the wrong status for client input and a leak of the parser's own message. Parsing is now guarded separately. A token that cannot be parsed is the same rejection as one that parses but does not verify, so it returns the existing 400 "Apple ID authentication is not valid." and logs the parser message rather than returning it. loginWithGoogle already answered 400 for equally malformed input — this brings Apple in line with it. The existing test asserted the 500, pinning the defect; the same test asserts Google's 400 three lines further down. That assertion is corrected and now also checks the message. Co-Authored-By: Claude Opus 5 --- .../Controllers/v1/CustomerController.php | 21 +++++++++++++++++-- .../CustomerControllerContractsTest.php | 8 ++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 212b422..69b1d51 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -518,8 +518,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); } diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 5211b88..efa8e14 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -578,7 +578,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.']); From b23c0298c64dbf76e10b5619ca01b851a07f42ba Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 11 Aug 2026 16:56:38 +0800 Subject: [PATCH 10/26] fix(api): creating a review without a subject answered 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReviewController::create() resolves the subject with Utils::resolveSubject($request->input('subject')), whose parameter is a non-nullable string. A request that omitted `subject` therefore threw Utils::resolveSubject(): Argument #1 ($publicId) must be of type string, null given as a 500 — before the controller's own `if (!$subject)` guard could run. That guard was unreachable for the commonest way to get the request wrong. `subject` is now required in CreateReviewRequest, so a missing one is a 422 and the existing guard handles the present-but-unresolvable case it was written for. Surfaced by the contract run once the collection stopped sending an empty `content` and the request got far enough to reach this line. Co-Authored-By: Claude Opus 5 --- server/src/Http/Requests/CreateReviewRequest.php | 8 ++++++++ server/tests/Unit/Http/Requests/RequestContractsTest.php | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/server/src/Http/Requests/CreateReviewRequest.php b/server/src/Http/Requests/CreateReviewRequest.php index 270ed4e..470ab7b 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/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index 160cbeb..b807559 100644 --- a/server/tests/Unit/Http/Requests/RequestContractsTest.php +++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php @@ -131,7 +131,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', From fe41cada4b74e241941c943ffa42b4db0fbe5f5d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 11 Aug 2026 22:31:19 +0800 Subject: [PATCH 11/26] fix(api): SMS login answered 500 when the store has no Twilio credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loginWithPhone() called VerificationCode::generateSmsVerificationFor() unguarded. The Twilio SDK THROWS when no credentials are configured — ConfigurationException, "Credentials are required to create a Client" — so POST /storefront/v1/customers/login-with-sms answered 500 with an HTML stack trace for any store that has simply not set up SMS. That is a configuration state, not a server error. The SMS attempt is now guarded and falls back to email, mirroring FleetOps' DriverController::loginWithPhone, which has handled this since it was written. A store without Twilio can still authenticate customers who have an email address. The response reports `method` so the client knows which channel carried the code; both branches were previously indistinguishable. Note the fallback leaves two verification_codes rows — generateSmsVerificationFor persists the code before attempting delivery, so the failed SMS attempt leaves its row behind. Both are valid for the subject and purpose, so either verifies. FleetOps behaves identically. Asserted rather than changed. Co-Authored-By: Claude Opus 5 --- .../Controllers/v1/CustomerController.php | 47 ++++++++-- .../CustomerControllerContractsTest.php | 89 ++++++++++++++++++- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 69b1d51..c8e82d8 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -472,14 +472,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}"; + }; + + 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()->json(['status' => 'OK']); + return response()->apiError('Unable to send verification code.'); } /** diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index efa8e14..96ff132 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 @@ -603,6 +608,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) { @@ -728,6 +750,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', ])); @@ -747,6 +774,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); @@ -1231,13 +1259,72 @@ public function clear(): void $response = (new CustomerController())->loginWithPhone(); app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); - expect($response->getData(true))->toBe(['status' => 'OK']) + expect($response->getData(true))->toBe(['status' => 'OK', 'method' => 'sms']) ->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 suite runs in ONE process, so a + // throwing `twilio` left bound here fails every later test that sends an SMS. + app()->forgetInstance('twilio'); + Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); + 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 password login reuses the storefront contact and issues an access token', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); From 5e18dc89279da1677f43d9a136f8fb27bf5d11d0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 11 Aug 2026 22:48:13 +0800 Subject: [PATCH 12/26] fix(api): account closure and phone verification leaked provider errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more paths where an unconfigured SMS provider surfaced as an API error carrying the Twilio SDK's own message, "Credentials are required to create a Client". startAccountClosure() chose its channel with `if ($user->phone) ... elseif ($user->email)`, so a customer WITH a phone never reached the email branch: the SMS throw went straight to the catch and returned the SDK message. The SMS attempt is now guarded on its own and falls back to email, matching loginWithPhone. That fallback breaks a coupling worth making explicit. confirmAccountClosure looks the code up by `meta->identity`, computed as `$user->phone ?? $user->email`. Previously SMS filed it under the phone and email under the email, which agreed only because the same precedence chose the channel. Both branches now file it under that identity, so an emailed code is still findable for a customer who has a phone. requestPhoneVerification() has no email fallback — verifying a phone number by email would not verify anything — so it returns "Unable to send phone verification code." rather than the provider's message. Both paths report to Sentry when bound. Also restores `mail.manager` in the email-fallback test added in fe41cad. The file runs in one process, and leaving the working mailer bound made a later test's deliberately-failing email delivery succeed. Co-Authored-By: Claude Opus 5 --- .../Controllers/v1/CustomerController.php | 70 +++++++++++++----- .../CustomerControllerContractsTest.php | 74 ++++++++++++++++++- 2 files changed, 125 insertions(+), 19 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index c8e82d8..2b32b63 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -989,31 +989,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) @@ -1099,8 +1125,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.'); } } diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 96ff132..27da84b 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -1308,10 +1308,14 @@ public function message(string $to, string $message, array $media = [], array $p $response = (new CustomerController())->loginWithPhone(); - // Restore the container before asserting. The whole suite runs in ONE process, so a - // throwing `twilio` left bound here fails every later test that sends an SMS. + // 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 @@ -1325,6 +1329,72 @@ public function message(string $to, string $message, array $media = [], array $p ])->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('customer password login reuses the storefront contact and issues an access token', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); From cefc1ca880d5f01fe516f5da72601a4ff6449fa1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 11 Aug 2026 23:33:00 +0800 Subject: [PATCH 13/26] feat: harden marketplace API workflows --- README.md | 20 ++ .../Http/Controllers/NetworkController.php | 1 + .../Controllers/v1/CategoryController.php | 35 ++- .../Controllers/v1/CheckoutController.php | 75 ++++- .../Http/Controllers/v1/NetworkController.php | 127 +++++---- .../Http/Controllers/v1/ProductController.php | 32 ++- .../Http/Controllers/v1/ReviewController.php | 57 ++-- .../Controllers/v1/ServiceQuoteController.php | 69 +++-- .../Http/Controllers/v1/StoreController.php | 45 ++- server/src/Http/Resources/Cart.php | 20 +- server/src/Http/Resources/Product.php | 1 + server/src/Http/Resources/Store.php | 1 + server/src/Models/Cart.php | 27 +- .../CategoryApiControllerContractsTest.php | 18 +- .../CheckoutBoundaryContractsTest.php | 267 +++++++++++++++++- .../CustomerControllerContractsTest.php | 19 +- .../NetworkApiControllerContractsTest.php | 120 +++++++- .../ProductApiControllerContractsTest.php | 91 +++++- .../PublicCommerceControllerContractsTest.php | 157 ++++++++++ .../ReviewAndOrderControllerContractsTest.php | 47 ++- .../ServiceQuoteControllerContractsTest.php | 128 ++++++++- .../SmallControllerContractsTest.php | 115 +++++++- server/tests/Unit/Models/CartTest.php | 16 ++ .../NotificationContractsTest.php | 7 +- server/tests/Unit/Support/QPayTest.php | 5 +- server/tests/Unit/Support/StorefrontTest.php | 32 ++- 26 files changed, 1354 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index bee60dd..d36b49e 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/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index cff2747..bb240e1 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/CategoryController.php b/server/src/Http/Controllers/v1/CategoryController.php index 54d7ae7..4d46be9 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 b5fc318..dc08be2 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) @@ -176,7 +243,7 @@ protected function applyFoodTruckOrderData(?FoodTruck $foodTruck, array $orderMe * unaffected: with no token the body parameter is still used, since a guest has no * token to present. * - * @return \Fleetbase\Storefront\Models\Customer|\Illuminate\Http\JsonResponse|null + * @return Customer|\Illuminate\Http\JsonResponse|null */ protected static function resolveCheckoutCustomer(?string $customerId) { @@ -224,7 +291,11 @@ 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 = static::resolveCheckoutCustomer($customerId); if ($customer instanceof \Illuminate\Http\JsonResponse) { diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index f974608..497cc89 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,10 @@ public function stores(Request $request) */ public function storeLocations(Request $request) { + if (session('storefront_store') || !session('storefront_network')) { + return response()->error('Store locations can only be queried for a network!'); + } + $limit = $request->input('limit', 30); $ids = $request->input('ids', []); $exclude = $request->input('exclude', []); @@ -218,7 +233,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,6 +274,10 @@ public function storeLocations(Request $request) */ public function tags(Request $request) { + if (session('storefront_store') || !session('storefront_network')) { + return response()->error('Tags can only be queried for a network!'); + } + $tags = []; $stores = Store::select(['tags']) diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php index 456ec44..e0503e9 100644 --- a/server/src/Http/Controllers/v1/ProductController.php +++ b/server/src/Http/Controllers/v1/ProductController.php @@ -341,11 +341,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 +375,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'))); + }) + ->where('is_available', 1) + ->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 90a69ec..30b8462 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -17,6 +17,30 @@ 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'), fn ($query) => $query->where('subject_uuid', session('storefront_store'))) + ->when(session('storefront_network'), function ($query) { + $memberStoreUuids = Store::select('uuid') + ->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $query->whereIn('subject_uuid', $memberStoreUuids); + }) + ->first(); + } + /** * Query for Storefront Review resources. * @@ -49,12 +73,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 +87,7 @@ public function query(Request $request) } if ($offset) { - $query->limit($offset); + $query->offset($offset); } }); } @@ -130,12 +149,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 +175,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 +205,7 @@ public function create(CreateReviewRequest $request) $subject = Utils::resolveSubject($request->input('subject')); - if (!$subject) { + if (!$subject || ($subject instanceof Store && !$this->resolveStoreForContext($subject->public_id))) { return response()->error('Invalid subject for review'); } @@ -247,11 +264,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 03407fc..cee388a 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 303356a..bc1ae56 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,17 @@ 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} @@ -205,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'); } } @@ -214,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/Resources/Cart.php b/server/src/Http/Resources/Cart.php index 593e851..840d80c 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 9dced7e..db403db 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 51efd24..f515a41 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 c9e7716..c5af147 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'))); + }) + ->where('is_available', 1) + ->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 482ced3..45d838c 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 50954b1..b75ccb4 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([ @@ -898,6 +1148,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' => '[]', @@ -1567,6 +1818,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'items' => [ [ 'id' => 'line_one', + 'product_id' => null, 'name' => 'Delivery box', 'quantity' => 2, 'price' => 1000, @@ -1659,7 +1911,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 +1930,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 +2006,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 +2021,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 +2052,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 +2076,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 +2160,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([ @@ -2226,7 +2479,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 27da84b..3f1bb04 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -461,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', [ @@ -491,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'); @@ -513,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) @@ -540,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.', ]) @@ -1301,7 +1316,7 @@ public function clear(): void 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'); + throw new RuntimeException('Credentials are required to create a Client'); } }); Illuminate\Support\Facades\Facade::clearResolvedInstance('twilio'); diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php index 52dc71b..6dd7bfe 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,18 @@ function createNetworkApiControllerSchema(): void ->and($offsetResource->resource)->toHaveCount(1) ->and($offsetResource->resource->first()->uuid)->toBe('location_other_uuid'); }); + +test('network-only endpoints reject store and missing storefront contexts', function () { + $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')); + + expect($storeLocations->getStatusCode())->toBe(400) + ->and($storeTags->getStatusCode())->toBe(400) + ->and($missingLocations->getStatusCode())->toBe(400); +}); diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php index 01200d3..6c2a5e7 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([ @@ -240,6 +243,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 +251,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'store_uuid' => 'outside_store_uuid', 'name' => 'Outside product', 'is_available' => true, + 'status' => 'published', ], ]); session([ @@ -354,7 +359,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 +450,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 +546,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 +556,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'category_uuid' => 'other_category', 'name' => 'Food', 'is_available' => true, + 'status' => 'published', ], ]); session([ @@ -528,6 +614,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 +629,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 02618c9..e186203 100644 --- a/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php @@ -127,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(); diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 99391a8..b3fc1d7 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -434,9 +434,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 +463,14 @@ 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', + ], ]); session([ 'company' => 'company_uuid', @@ -491,6 +498,8 @@ 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'); expect($missing->getStatusCode())->toBe(400) ->and($missing->getData(true))->toBe(['error' => 'Cannot find reviews for store']) @@ -498,6 +507,8 @@ 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($counts->getData(true))->toBe([ 1 => 1, 2 => 0, @@ -521,26 +532,26 @@ 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('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()], ]); + session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); $controller = new ReviewController(); $found = $controller->find('review_abcdefgh'); + $foreign = $controller->find('review_foreign'); $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($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 () { @@ -679,6 +690,10 @@ function createReviewControllerSchema(): void ]) ); $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,6 +722,8 @@ 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($created->resource->uuid)->toBe($review->uuid) @@ -720,7 +737,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 00abe37..c967b64 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 fb517bc..3bdbd16 100644 --- a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php @@ -506,6 +506,112 @@ 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(); @@ -742,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([ @@ -752,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/Models/CartTest.php b/server/tests/Unit/Models/CartTest.php index e3c506e..da61660 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 d39f54b..1c9db67 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 17c59f5..d4d15d1 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 8c5cc7b..5c58d10 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']); From 908fbd177141e07fab2897986c47443f6a8619fd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 12 Aug 2026 00:01:02 +0800 Subject: [PATCH 14/26] fix: preserve store marketplace contracts --- .../Http/Controllers/v1/NetworkController.php | 32 ++++-- .../Http/Controllers/v1/ProductController.php | 4 +- .../Http/Controllers/v1/ReviewController.php | 35 +++++- server/src/Models/Cart.php | 4 +- .../NetworkApiControllerContractsTest.php | 25 ++++- .../ProductApiControllerContractsTest.php | 7 +- .../ReviewAndOrderControllerContractsTest.php | 100 ++++++++++++++++-- 7 files changed, 174 insertions(+), 33 deletions(-) diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index 497cc89..d6514f7 100644 --- a/server/src/Http/Controllers/v1/NetworkController.php +++ b/server/src/Http/Controllers/v1/NetworkController.php @@ -183,8 +183,11 @@ public function stores(Request $request) */ public function storeLocations(Request $request) { - if (session('storefront_store') || !session('storefront_network')) { - return response()->error('Store locations can only be queried for a network!'); + $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); @@ -214,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) { @@ -274,17 +279,22 @@ public function storeLocations(Request $request) */ public function tags(Request $request) { - if (session('storefront_store') || !session('storefront_network')) { - return response()->error('Tags can only be queried for a network!'); + $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 e0503e9..5201c46 100644 --- a/server/src/Http/Controllers/v1/ProductController.php +++ b/server/src/Http/Controllers/v1/ProductController.php @@ -381,9 +381,9 @@ public function find($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'); }) - ->where('is_available', 1) - ->where('status', 'published') ->with(['addonCategories.category', 'variants.options', 'files']) ->first(); diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index 30b8462..dd51ba7 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; @@ -32,15 +33,43 @@ 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'), fn ($query) => $query->where('subject_uuid', session('storefront_store'))) + ->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'))); - $query->whereIn('subject_uuid', $memberStoreUuids); + $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. * @@ -205,7 +234,7 @@ public function create(CreateReviewRequest $request) $subject = Utils::resolveSubject($request->input('subject')); - if (!$subject || ($subject instanceof Store && !$this->resolveStoreForContext($subject->public_id))) { + if (!$subject || !$this->subjectBelongsToContext($subject)) { return response()->error('Invalid subject for review'); } diff --git a/server/src/Models/Cart.php b/server/src/Models/Cart.php index c5af147..4e65bfe 100644 --- a/server/src/Models/Cart.php +++ b/server/src/Models/Cart.php @@ -705,9 +705,9 @@ public static function findProduct(string $id): ?Product ->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'); }) - ->where('is_available', 1) - ->where('status', 'published') ->with(['store.locations']) ->first(); } diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php index 6dd7bfe..60591b7 100644 --- a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php @@ -582,7 +582,22 @@ function createNetworkApiControllerSchema(): void ->and($offsetResource->resource->first()->uuid)->toBe('location_other_uuid'); }); -test('network-only endpoints reject store and missing storefront contexts', function () { +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]); @@ -591,8 +606,10 @@ function createNetworkApiControllerSchema(): void session(['storefront_store' => null, 'storefront_network' => null]); $missingLocations = $controller->storeLocations(Request::create('/network/store-locations')); + $missingTags = $controller->tags(Request::create('/network/tags')); - expect($storeLocations->getStatusCode())->toBe(400) - ->and($storeTags->getStatusCode())->toBe(400) - ->and($missingLocations->getStatusCode())->toBe(400); + 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 6c2a5e7..2c9c11f 100644 --- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -206,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 () { diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index b3fc1d7..14c8619 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 () { @@ -471,6 +490,26 @@ function createReviewControllerSchema(): void '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', @@ -498,8 +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'); + $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']) @@ -509,6 +553,10 @@ function createReviewControllerSchema(): void ->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, @@ -535,20 +583,30 @@ function createReviewControllerSchema(): void 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' => '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'); - $foreign = $controller->find('review_foreign'); - $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($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(); @@ -666,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( @@ -673,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( @@ -689,6 +756,19 @@ 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', @@ -726,7 +806,9 @@ public function put(string $path, string $contents, string $visibility): bool $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') From bc3a773d1598b3f7c96ea3f8489d10f14f9ec341 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 12 Aug 2026 14:08:46 +0800 Subject: [PATCH 15/26] fix(api): apply the review-account bypass to phone verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bypass exists so App Store review can complete flows that would otherwise need a live SMS provider. It was wired into verifyCode and confirmAccountClosure but never into phone verification, so that flow still required Twilio outright — requestPhoneVerification returned an error and verifyPhoneNumber could not be reached at all. Both ends now honour it, with the same allowlist and constant-time code check as every other call site: requestPhoneVerification a review account needs no message delivered, so the send is skipped and the response reports method=bypass verifyPhoneNumber accepts the configured code for a listed account. 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 A code that is not the bypass is still rejected for the same account, which the test asserts alongside the happy path. Co-Authored-By: Claude Opus 5 --- .../Controllers/v1/CustomerController.php | 19 +++ .../CustomerControllerContractsTest.php | 117 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 2b32b63..366d0c2 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -1116,6 +1116,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) { @@ -1176,7 +1184,18 @@ 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!'); } diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 3f1bb04..241bbd5 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -1410,6 +1410,123 @@ public function sendNow($notifiables, $notification) ->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!']); + + 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(); From 69dcf9cad9cf0686e1e2203ddec91fee0da2e620 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 12 Aug 2026 17:46:21 +0800 Subject: [PATCH 16/26] ci(contract): run the API contract once per commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pull request from a branch in this repo fires both `push` and `pull_request` for the same commit, so the contract ran twice — two stack boots, two seeds, two full collection runs for one change. Keying the concurrency group on the COMMIT rather than the ref collapses them: the pull_request event reports the head SHA and the push event reports the same commit as github.sha, so both land in one group and the older is cancelled. Deduped rather than dropping a trigger, so a direct push to a release branch is still verified instead of relying on someone remembering to dispatch it. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 3d3264f..f3b8d75 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -21,6 +21,17 @@ on: branches: [main] workflow_dispatch: +# A pull request from a branch in this repo fires BOTH `push` and `pull_request` for the +# same commit, so the contract ran twice: two stack boots, two seeds, two collection runs. +# Keying the group on the commit rather than the ref collapses them — the pull_request +# event reports the head SHA, the push event reports the same commit as github.sha. +# +# Deduped rather than dropping a trigger, so a direct push to a release branch is still +# verified instead of relying on someone remembering to dispatch it. +concurrency: + group: api-contract-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: true + permissions: contents: read From 2b1da5ef102c5f6029724797d086380a3ad1663d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 12 Aug 2026 18:41:56 +0800 Subject: [PATCH 17/26] Revert "ci(contract): run the API contract once per commit" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancel-in-progress dedupes by cancelling, which surfaces cancelled runs on the pull request. That reads as something having gone wrong, and is a worse trade than the duplicate run it removes. Reverted to the previous triggers. The duplicate run on same-repo pull requests is back, and is worth solving properly — most likely by narrowing the triggers so only one event fires, rather than starting a run and killing it. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index f3b8d75..cde939b 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -20,18 +20,6 @@ on: pull_request: branches: [main] workflow_dispatch: - -# A pull request from a branch in this repo fires BOTH `push` and `pull_request` for the -# same commit, so the contract ran twice: two stack boots, two seeds, two collection runs. -# Keying the group on the commit rather than the ref collapses them — the pull_request -# event reports the head SHA, the push event reports the same commit as github.sha. -# -# Deduped rather than dropping a trigger, so a direct push to a release branch is still -# verified instead of relying on someone remembering to dispatch it. -concurrency: - group: api-contract-${{ github.event.pull_request.head.sha || github.sha }} - cancel-in-progress: true - permissions: contents: read From 21494390bbcd0ae9e024e8c8f0fe639d38fb5533 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 13 Aug 2026 12:51:26 +0800 Subject: [PATCH 18/26] fix(checkout): accept the documented service_quote spelling in validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckoutController reads the quote with or(['serviceQuote', 'service_quote']), so both spellings work downstream. InitializeCheckoutRequest validated only the camelCase one, so a snake_case request was rejected with "The service quote field is required." before the controller ever ran — even with a valid quote id present in the query string. That is the spelling our own API reference documents and the one the contract collection sends, so /checkouts/before was unreachable for delivery checkouts via the documented payload. Either spelling now satisfies the requirement; sending neither on a delivery checkout still fails as before, and pickup checkouts still require neither. Co-Authored-By: Claude Opus 5 --- .../Requests/InitializeCheckoutRequest.php | 11 ++++++++--- .../Http/Requests/RequestContractsTest.php | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Requests/InitializeCheckoutRequest.php b/server/src/Http/Requests/InitializeCheckoutRequest.php index e06c593..b2d7414 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/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index b807559..4185e88 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 () { From 44c0581c20e9a21992a2f5144f96ab06e3e9dfaa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 13 Aug 2026 13:13:08 +0800 Subject: [PATCH 19/26] fix(session): clear the previous storefront scope when the key type changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setKey() only ever added keys. Storefront sessions are cookie-backed, so a client that calls a network endpoint and then a store endpoint reuses the same session and ended up with BOTH storefront_store and storefront_network set. Every query that branches on those two applies them additively: ->when(session('storefront_store'), ...->where('store_uuid', ...)) ->when(session('storefront_network'), ...whereHas('store.networks') ->where('is_available', 1) ->where('status', 'published')) so a store-scoped request silently picked up the stricter network filter as well. Cart::findProduct then returned null for a product that plainly belongs to the store, and the cart answered "Invalid product provided to cart!" — which is what the API contract run hits: List Network Stores (network key) runs before the Cart folder (store key), and products created through the API have status NULL, so the leaked network branch excluded them. ProductController::find and query carry the same both-branches shape and were mis-scoped the same way. Each request re-runs this middleware with its own key, and setKey() is the only writer of these session keys, so clearing the opposing scope is safe. Co-Authored-By: Claude Opus 5 --- .../Http/Middleware/SetStorefrontSession.php | 14 ++++- .../Middleware/SetStorefrontSessionTest.php | 52 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/server/src/Http/Middleware/SetStorefrontSession.php b/server/src/Http/Middleware/SetStorefrontSession.php index c787aee..0f56bfb 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/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php b/server/tests/Unit/Http/Middleware/SetStorefrontSessionTest.php index c63951a..7074028 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(); From 58f956c1b485056a0ed1012bd58b1feb83c00d0b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 13 Aug 2026 14:15:11 +0800 Subject: [PATCH 20/26] fix(products): let the public API create a product that anything can read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 controller writes it. CreateProductRequest allowed only draft, active and archived, and `status` is a nullable column with no default, so a product created through the public API landed as NULL and could never satisfy any of them: - a network storefront never listed it - CheckoutController:166 looks cart items up with ->where('is_available', 1)->where('status', 'published'), so it was dropped at capture time for store-scoped merchants too Allow `published` and default to it on create, matching the console. UpdateProductRequest is an empty subclass, so the one rule edit covers both. An explicit status is still honoured. No migration needed: existing rows are already `published` (console) or NULL/draft (API), and nothing that reads `published` gets stricter. Co-Authored-By: Claude Opus 5 --- .../Http/Controllers/v1/ProductController.php | 6 +++++ .../Http/Requests/CreateProductRequest.php | 7 ++++- .../ProductApiControllerContractsTest.php | 27 +++++++++++++++++++ .../Http/Requests/RequestContractsTest.php | 6 ++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php index 5201c46..7b20375 100644 --- a/server/src/Http/Controllers/v1/ProductController.php +++ b/server/src/Http/Controllers/v1/ProductController.php @@ -68,6 +68,12 @@ 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. + $input['status'] = data_get($input, 'status') ?: Product::PUBLISHED; + // Resolve category if ($request->filled('category')) { $categoryInput = $request->input('category'); diff --git a/server/src/Http/Requests/CreateProductRequest.php b/server/src/Http/Requests/CreateProductRequest.php index 1ed8c56..e63f5a3 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/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php index 2c9c11f..f20ede2 100644 --- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -3,6 +3,7 @@ use Fleetbase\Storefront\Http\Controllers\v1\ProductController; use Fleetbase\Storefront\Http\Requests\CreateProductRequest; use Fleetbase\Storefront\Http\Requests\UpdateProductRequest; +use Fleetbase\Storefront\Models\Product; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Session\ArraySessionHandler; @@ -305,6 +306,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(Product::PUBLISHED) + ->and($explicit->status)->toBe('draft'); +}); + test('product creation persists category addons variants and option contracts', function () { createProductApiControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index 4185e88..d8840dd 100644 --- a/server/tests/Unit/Http/Requests/RequestContractsTest.php +++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php @@ -131,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'); }); From d93ac9ae41a7177ef5c04b5377e7c138997bf330 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 13 Aug 2026 14:15:11 +0800 Subject: [PATCH 21/26] fix(cart): stop mutating carts that have already been checked out retrieveCart() named its second parameter `$create` and passed it straight into Cart::retrieve(), whose second parameter is `$excludeCheckedout`. The GET action passed true; add, update, remove, empty and delete all took the false default and so operated on carts that had already produced an order. The name made that read as deliberate. Drop the passthrough and let Cart::retrieve() apply its own `true` default everywhere. Behaviour change worth noting in the release: a cart id whose checkout produced an order is no longer found, so Cart::retrieve() falls through to newCart() and the client gets a fresh empty cart rather than editing a historical one. Co-Authored-By: Claude Opus 5 --- .../Http/Controllers/v1/CartController.php | 14 +++++++++++--- .../PublicCommerceControllerContractsTest.php | 19 +++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/server/src/Http/Controllers/v1/CartController.php b/server/src/Http/Controllers/v1/CartController.php index dcffded..af5aa9f 100644 --- a/server/src/Http/Controllers/v1/CartController.php +++ b/server/src/Http/Controllers/v1/CartController.php @@ -9,9 +9,17 @@ 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); } /** @@ -33,7 +41,7 @@ protected function retrieveCart(?string $uniqueId, bool $create = false): Cart */ 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/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php b/server/tests/Unit/Http/Controllers/PublicCommerceControllerContractsTest.php index e186203..a4adbdd 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; } @@ -377,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', From fd9e66fe0e573e4da00f5b28eb3e878ca72707c5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 13 Aug 2026 14:15:24 +0800 Subject: [PATCH 22/26] fix(customers): resolve the verification identity from the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create() read `identity` and nothing else. A body of {name, email, code} — which is what the documented flow produces after verifying an email — left it null, static::phone() turned that into the literal '+', and the lookup on meta->identity could never match. A correctly issued code came back "Invalid verification code provided!". Fall back to the payload's own email, then phone, before giving up. static::phone() now returns null instead of a bare '+' when there is nothing to format. That '+' was also being written into contacts.phone and users.phone for every customer created without a phone number. Returning null makes `where('phone', $x)` compile to `phone IS NULL`, which would match an arbitrary phone-less user, so the four lookups that could receive it are guarded: login, loginWithPhone, verifyCode and requestPhoneVerification now bail with their existing error rather than searching on null. Also guarded verifyPhoneNumber's $verificationCode->meta['phone'] subscript — a row written by anything other than requestPhoneVerification turned a recoverable 400 into a 500. Co-Authored-By: Claude Opus 5 --- .../Controllers/v1/CustomerController.php | 61 ++++++++++++++++--- .../CustomerControllerContractsTest.php | 56 +++++++++++++++++ 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 366d0c2..d9ef326 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -22,10 +22,10 @@ use Fleetbase\Storefront\Http\Resources\Customer; use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; -use Illuminate\Support\Facades\Log; 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 @@ -176,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) { @@ -415,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); @@ -462,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(); @@ -825,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(); @@ -883,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; } @@ -1107,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); @@ -1199,8 +1241,13 @@ public function verifyPhoneNumber(Request $request) 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/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 241bbd5..37b86fd 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -901,6 +901,53 @@ 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([ + 'uuid' => 'verification_uuid', + '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'); @@ -1816,6 +1863,15 @@ public function sendNow($notifiables, $notification) 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(); From bc11657aeb7379d1b35637a90d335974d9352642 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 13 Aug 2026 14:22:24 +0800 Subject: [PATCH 23/26] fix(tests): repair the two breaks the last three commits introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP CI came back red on the previous push. Two causes, both mine: - the new create-customer test inserted a `uuid` column into the throwaway verification_codes table it builds, which has no such column - Product::PUBLISHED does not resolve under the unit-test harness — reproducible on a whole-suite run, not just an isolated file — so ProductController::create now writes the status literally, as the rest of that controller already does Full suite verified locally under PHP 8.2: 0 failures. Worth recording for anyone debugging this suite: the pest runner emits no output at all here, in CI or locally, and reports only an exit code. `--log-junit` is the way to see which tests failed and why. Co-Authored-By: Claude Opus 5 --- server/src/Http/Controllers/v1/ProductController.php | 5 ++++- .../Http/Controllers/CustomerControllerContractsTest.php | 1 - .../Http/Controllers/ProductApiControllerContractsTest.php | 3 +-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/server/src/Http/Controllers/v1/ProductController.php b/server/src/Http/Controllers/v1/ProductController.php index 7b20375..01a9566 100644 --- a/server/src/Http/Controllers/v1/ProductController.php +++ b/server/src/Http/Controllers/v1/ProductController.php @@ -72,7 +72,10 @@ public function create(CreateProductRequest $request) // 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. - $input['status'] = data_get($input, 'status') ?: Product::PUBLISHED; + // 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')) { diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 37b86fd..ed9715f 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -918,7 +918,6 @@ public function message(string $to, string $message, array $media = [], array $p $table->timestamp('deleted_at')->nullable(); }); $connection->table('verification_codes')->insert([ - 'uuid' => 'verification_uuid', 'code' => '123456', 'for' => 'storefront_create_customer', 'meta' => json_encode(['identity' => 'buyer@example.test']), diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php index f20ede2..53b61b5 100644 --- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -3,7 +3,6 @@ use Fleetbase\Storefront\Http\Controllers\v1\ProductController; use Fleetbase\Storefront\Http\Requests\CreateProductRequest; use Fleetbase\Storefront\Http\Requests\UpdateProductRequest; -use Fleetbase\Storefront\Models\Product; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Session\ArraySessionHandler; @@ -328,7 +327,7 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar 'status' => 'draft', ]))->resource; - expect($defaulted->status)->toBe(Product::PUBLISHED) + expect($defaulted->status)->toBe('published') ->and($explicit->status)->toBe('draft'); }); From 9391bd63aed3648ff31e04e73550a85b9c4561f7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 13 Aug 2026 21:49:31 +0800 Subject: [PATCH 24/26] fix(checkout): return the checkout id, not just the token GET /checkouts/status matches on public_id AND token: Checkout::where('public_id', $checkoutId)->where('token', $token)->first() but the chkt_* public id and the checkout_* token are different values, and only initializeQPayCheckout was returning the id. The cash, card and payment-intent-update paths returned the token alone, so a client following the documented flow had no way to learn its own checkout's id and the status endpoint was unreachable for them. Add `checkout` to the three responses that omitted it, mirroring what the QPay path already does. Purely additive, so no existing client breaks. Co-Authored-By: Claude Opus 5 --- .../src/Http/Controllers/v1/CheckoutController.php | 13 +++++++++++-- .../Controllers/CheckoutBoundaryContractsTest.php | 6 ++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index dc08be2..4ca11f8 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -369,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, ]); } @@ -466,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, ]); } @@ -689,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/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index b75ccb4..8468256 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -1130,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') @@ -1652,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() @@ -1870,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') @@ -2256,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'); From 25a1de1b8440a09fedcbf5579bbeb8c18db4fa9a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 14 Aug 2026 14:21:19 +0800 Subject: [PATCH 25/26] test(customers): cover the four null-identity guards Codecov flagged the branch at 99.94% project / 99.14% patch. All four uncovered statements were guards added with the static::phone() change, and each one protects a lookup that would otherwise search on null: loginWithPhone where('phone', null) -> `phone IS NULL` verifyCode phone = $x OR email = $x -> both IS NULL requestPhoneVerification findExistingUserByPhone(string) -> TypeError on null verifyPhoneNumber $verificationCode->meta['phone'] -> 500 instead of 400 Each is now exercised through the controller, asserting the error it returns rather than merely executing the line. Two of them need care that is worth recording: * verifyCode's case binds its request instead of only passing it. static::phone() falls back to request()->input('phone'), so an earlier bound request carrying a phone supplied an identity the call never sent, and the guard stayed unreached. It runs last in that test so the rebind affects nothing after it. * verifyPhoneNumber's case seeds a storefront_verify_phone row with an empty meta, which is the only way to reach a matched code that carries no phone. Local: 100.00% statements (7160/7160), whole suite green. Co-Authored-By: Claude Opus 5 --- .../CustomerControllerContractsTest.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index ed9715f..c9cba78 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -1320,7 +1320,14 @@ public function clear(): void $response = (new CustomerController())->loginWithPhone(); app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); + // 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', @@ -1567,6 +1574,32 @@ public function sendNow($notifiables, $notification) ); 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' => [], @@ -1698,10 +1731,22 @@ public function sendNow($notifiables, $notification) '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) From 0f341bb9ba5067597dc4b8ae8dc8812af1a0a7ea Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 17 Aug 2026 16:13:07 +0800 Subject: [PATCH 26/26] chore: update RELEASE.md --- RELEASE.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..df539b2 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,47 @@ +> v0.4.19 ~ "Marketplace storefronts with safer checkout and customer verification" + +--- +## Highlights + +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. + +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)