diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index 7d1b52c34..df7d1fa1b 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -2,11 +2,11 @@ name: Ember.js CI on: push: - branches: [ main ] + branches: [ main, 'dev-v*' ] tags: - 'v*' pull_request: - branches: [ main ] + branches: [ main, 'dev-v*' ] env: NODE_VERSION: 22.x diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 820bb0159..04fc271e7 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -4,22 +4,33 @@ name: API Contract (Postman) # collection against the live API. Delegates to the reusable workflow in # fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN # (inherited); no-ops until POSTMAN_API_KEY is set. -# TODO: change @dev-v0.7.53 to @main once that branch is merged. +# +# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main +# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and +# there is no ref here to remember to bump. Each run records the image digest it actually +# resolved in its job summary, so a result stays traceable. To reproduce an older run: +# +# with: +# fleetbase-ref: v0.7.53 +# api-image: fleetbase/fleetbase-api:v0.7.53 on: push: - branches: [main] + branches: [main, 'dev-v*'] pull_request: - branches: [main] + branches: [main, 'dev-v*'] workflow_dispatch: - permissions: contents: read jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main with: collections: "Fleetbase API" build-from-source: false + # Without this the run tests the version of fleetbase/fleetops-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/fleetops-api secrets: inherit diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index a47be23db..736f25296 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -2,11 +2,11 @@ name: PHP CI on: push: - branches: [ main ] + branches: [ main, 'dev-v*' ] tags: - 'v*' pull_request: - branches: [ main ] + branches: [ main, 'dev-v*' ] jobs: build: diff --git a/RELEASE.md b/RELEASE.md index 035f4ca38..29af9ef76 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,13 +1,34 @@ -> v0.6.59 ~ "RELEASE_NOTES_PLACEHOLDER — replace this line with the release title" +> v0.6.60 ~ "Closes an authentication bypass, and clears a run of API 500s" --- ## Highlights +A security fix and a broad sweep of public API defects surfaced by running the official Postman collection against a live stack. Several endpoints answered `500` where a `404` or `422` belonged, and a few were unreachable entirely. -RELEASE_NOTES_PLACEHOLDER +--- +## Security +- **Closed a verify-code authentication bypass in the driver flow.** Please upgrade. +- The non-production verification-code bypass is now scoped to explicitly designated review accounts, so a bypass code alone is not enough — the identity has to be on the allowlist too. + +--- +## Bug Fixes +- **Driver `register-device` was unreachable on both driver routes.** Laravel never injects a class-typed parameter that declares a default, so the injected request was always null. +- **Geofence driver history asked for a UUID the API never issues.** It now resolves the driver by the public id callers actually hold. +- **`/from-qr` returned a 500**, and the QR code's content is now published in debug mode so the flow can be exercised. +- **Fuel reports could not be created without a location**, and could not be updated. +- **A sensor could not be created at all** — `last_position` had no default. +- **Customer signup with a place failed** — the Place location now defaults. +- Unknown onboard organization answers `404` instead of `500`. +- Duplicate part SKU and fuel transaction answer `422` instead of `500`. +- Restored the vehicle maintenance schedule workflows. -Describe what changed in this release. The first line above must name the version -being released, and both placeholder markers must be gone, or the release workflow -refuses to tag. +--- +## Testing +- Coverage restored to 100% across the QR, geofence, driver auth, customer request and navigator changes. + +--- +## Continuous Integration +- The server, Ember and Postman workflows now run on `dev-v*` release branches. +- The contract run tests this branch's API code rather than the published package. --- ## Need help? diff --git a/addon/components/vehicle/details/schedules.hbs b/addon/components/vehicle/details/schedules.hbs index 5fc3f0b4e..859a72873 100644 --- a/addon/components/vehicle/details/schedules.hbs +++ b/addon/components/vehicle/details/schedules.hbs @@ -33,8 +33,8 @@

No maintenance schedules for this vehicle.

-
{{/if}} {{/if}} - \ No newline at end of file + diff --git a/addon/components/vehicle/details/schedules.js b/addon/components/vehicle/details/schedules.js index 40047565b..1531e2b94 100644 --- a/addon/components/vehicle/details/schedules.js +++ b/addon/components/vehicle/details/schedules.js @@ -1,12 +1,14 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; export default class VehicleDetailsSchedulesComponent extends Component { @service maintenanceScheduleActions; @service notifications; @service store; + @service vehicleActions; @tracked schedules = []; get resourceId() { @@ -22,11 +24,15 @@ export default class VehicleDetailsSchedulesComponent extends Component { try { this.schedules = yield this.store.query('maintenance-schedule', { subject_uuid: this.resourceId, - subject_type: 'vehicle', + subject_type: 'fleet-ops:vehicle', sort: '-created_at', }); } catch (err) { this.notifications.serverError(err); } } + + @action createSchedule() { + return this.vehicleActions.scheduleMaintenance(this.args.vehicle, {}, { refresh: false, callback: () => this.loadSchedules.perform() }); + } } diff --git a/addon/controllers/maintenance/schedules/index/edit.js b/addon/controllers/maintenance/schedules/index/edit.js index a49e61e9b..7fe1396de 100644 --- a/addon/controllers/maintenance/schedules/index/edit.js +++ b/addon/controllers/maintenance/schedules/index/edit.js @@ -21,12 +21,21 @@ export default class MaintenanceSchedulesIndexEditController extends Controller this.overlay?.close(); yield this.hostRouter.refresh(); yield this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index.details', schedule); - this.notifications.success(this.intl.t('common.resource-updated-success', { resource: this.intl.t('resource.maintenance-schedule') })); + this.notifyUpdateSuccess(schedule); } catch (err) { this.notifications.serverError(err); } } + notifyUpdateSuccess(schedule) { + this.notifications.success( + this.intl.t('common.resource-updated-success', { + resource: this.intl.t('resource.maintenance-schedule'), + resourceName: schedule.name, + }) + ); + } + @action cancel() { return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index'); } diff --git a/addon/services/vehicle-actions.js b/addon/services/vehicle-actions.js index 5d0922c57..6cc0fbe92 100644 --- a/addon/services/vehicle-actions.js +++ b/addon/services/vehicle-actions.js @@ -184,8 +184,10 @@ export default class VehicleActionsService extends ResourceActionService { }, }; - @action scheduleMaintenance(vehicle) { - this.maintenanceScheduleActions.modal.create({ subject: vehicle }); + @action async scheduleMaintenance(vehicle, options = {}, saveOptions = {}) { + vehicle = await this.resolveVehicleResource(vehicle); + + return this.maintenanceScheduleActions.modal.create({ subject: vehicle }, options, saveOptions); } @action createWorkOrder(vehicle) { diff --git a/composer.json b/composer.json index 95f0c6a28..14962f3fa 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.59", + "version": "0.6.60", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index af9d83e6e..2e4c92307 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.59", + "version": "0.6.60", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 8dd5aec4b..30ee09be9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.59", + "version": "0.6.60", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 2aba7e97d..2ae3d6c09 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -96,10 +96,54 @@ |-------------------------------------------------------------------------- */ 'navigator' => [ + /* + | App store reviewers cannot receive our SMS, so a fixed verification code has + | to keep working for them — including in production, which is where a review + | build is tested. Blocking the bypass outside production made review + | impossible; restricting it to named accounts makes it safe instead. + | + | Both values are required, and neither has a default. The code alone is not + | sufficient: it is only accepted for an identity listed in review_accounts, so + | a leaked code cannot be used to authenticate as an arbitrary driver. + | + | NAVIGATOR_BYPASS_VERIFICATION_CODE= + | NAVIGATOR_REVIEW_ACCOUNTS=+15555550100,apple-review@example.com + */ 'bypass_verification_code' => env('SMS_AUTH_BYPASS_CODE', env('NAVIGATOR_BYPASS_VERIFICATION_CODE')), + 'review_accounts' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('NAVIGATOR_REVIEW_ACCOUNTS', '')) + ))), 'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator'), ], + /* + |-------------------------------------------------------------------------- + | Customers + |-------------------------------------------------------------------------- + | + | Testing-only verification-code bypass for the customer auth flows + | (POST /v1/customers, /customers/verify-code, /customers/reset-password). + | Intended for local development and staging QA, where signing up means + | waiting on a real email or paying for a real SMS. + | + | MUST be left unset in production. It is ignored outright when the app + | environment is `production`, and when unset no bypass is possible. + | + | Deliberately NOT wired to SMS_AUTH_BYPASS_CODE: that variable already + | gates operator console login and driver login, and reusing it here would + | make one leaked value unlock three different privilege tiers. + | + */ + 'customers' => [ + 'verification_bypass_code' => env('FLEETOPS_CUSTOMER_VERIFICATION_BYPASS_CODE'), + // Identities the bypass code is accepted for. See the note on navigator below. + 'review_accounts' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('FLEETOPS_CUSTOMER_REVIEW_ACCOUNTS', '')) + ))), + ], + /* |-------------------------------------------------------------------------- | API Events diff --git a/server/config/geocoder.php b/server/config/geocoder.php index 00f0e3f58..789eac854 100644 --- a/server/config/geocoder.php +++ b/server/config/geocoder.php @@ -57,7 +57,7 @@ 'providers' => [ Chain::class => [ GoogleMaps::class => [ - config('services.google_maps.locale', env('GOOGLE_MAPS_LOCALE', 'us')), + config('services.google_maps.locale', env('GOOGLE_MAPS_LOCALE', 'en')), config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY')), ] ], diff --git a/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php b/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php new file mode 100644 index 000000000..60f5710f2 --- /dev/null +++ b/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php @@ -0,0 +1,152 @@ + (company_uuid, active_sku) ---- + if (!Schema::hasColumn('parts', 'active_sku')) { + Schema::table('parts', function (Blueprint $table) { + $table->string('active_sku')->nullable()->storedAs('IF(`deleted_at` IS NULL, `sku`, NULL)'); + }); + } + + // The replacement index MUST be created before the old one is dropped: + // parts_company_uuid_sku_unique is currently the only index covering + // company_uuid, and parts_company_uuid_foreign depends on it. Adding an + // index that also leads with company_uuid lets it take over as the + // covering index, otherwise the drop below fails with errno 150. + if (!$this->indexExists('parts', 'parts_company_uuid_active_sku_unique')) { + Schema::table('parts', function (Blueprint $table) { + $table->unique(['company_uuid', 'active_sku'], 'parts_company_uuid_active_sku_unique'); + }); + } + + if ($this->indexExists('parts', 'parts_company_uuid_sku_unique')) { + Schema::table('parts', function (Blueprint $table) { + $table->dropUnique('parts_company_uuid_sku_unique'); + }); + } + + // ---- fuel_provider_transactions: (provider, provider_transaction_id) + // -> (company_uuid, provider, active_provider_transaction_id) ---- + if (!Schema::hasColumn('fuel_provider_transactions', 'active_provider_transaction_id')) { + Schema::table('fuel_provider_transactions', function (Blueprint $table) { + $table->string('active_provider_transaction_id', 191)->nullable()->storedAs('IF(`deleted_at` IS NULL, `provider_transaction_id`, NULL)'); + }); + } + + if (!$this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_company_provider_unique')) { + Schema::table('fuel_provider_transactions', function (Blueprint $table) { + $table->unique(['company_uuid', 'provider', 'active_provider_transaction_id'], 'fuel_provider_txn_company_provider_unique'); + }); + } + + if ($this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_provider_unique')) { + Schema::table('fuel_provider_transactions', function (Blueprint $table) { + $table->dropUnique('fuel_provider_txn_provider_unique'); + }); + } + } + + /** + * Reverse the migrations. + * + * Restoring the old global index can fail if rows now exist that only the new + * key permits (two tenants sharing a provider transaction id, or a live row + * whose SKU matches a soft-deleted one). That is inherent to reversing a + * relaxed constraint, so the old indexes are restored on a best-effort basis + * and the generated columns are dropped either way. + * + * @return void + */ + public function down() + { + if (!$this->indexExists('parts', 'parts_company_uuid_sku_unique')) { + try { + Schema::table('parts', function (Blueprint $table) { + $table->unique(['company_uuid', 'sku'], 'parts_company_uuid_sku_unique'); + }); + } catch (Throwable $e) { + // Duplicate SKUs the new index allowed now block the old one. + } + } + + if ($this->indexExists('parts', 'parts_company_uuid_active_sku_unique')) { + Schema::table('parts', function (Blueprint $table) { + $table->dropUnique('parts_company_uuid_active_sku_unique'); + }); + } + + if (Schema::hasColumn('parts', 'active_sku')) { + Schema::table('parts', function (Blueprint $table) { + $table->dropColumn('active_sku'); + }); + } + + if (!$this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_provider_unique')) { + try { + Schema::table('fuel_provider_transactions', function (Blueprint $table) { + $table->unique(['provider', 'provider_transaction_id'], 'fuel_provider_txn_provider_unique'); + }); + } catch (Throwable $e) { + // Cross-tenant duplicates the new index allows now block the old one. + } + } + + if ($this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_company_provider_unique')) { + Schema::table('fuel_provider_transactions', function (Blueprint $table) { + $table->dropUnique('fuel_provider_txn_company_provider_unique'); + }); + } + + if (Schema::hasColumn('fuel_provider_transactions', 'active_provider_transaction_id')) { + Schema::table('fuel_provider_transactions', function (Blueprint $table) { + $table->dropColumn('active_provider_transaction_id'); + }); + } + } + + private function indexExists(string $table, string $index): bool + { + $database = DB::connection()->getDatabaseName(); + + return DB::table('information_schema.statistics') + ->where('table_schema', $database) + ->where('table_name', $table) + ->where('index_name', $index) + ->exists(); + } +}; diff --git a/server/src/Http/Controllers/Api/v1/CustomerController.php b/server/src/Http/Controllers/Api/v1/CustomerController.php index 149554d5d..6a37525a6 100644 --- a/server/src/Http/Controllers/Api/v1/CustomerController.php +++ b/server/src/Http/Controllers/Api/v1/CustomerController.php @@ -19,6 +19,7 @@ use Fleetbase\FleetOps\Support\CustomerAuth; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; +use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint; use Fleetbase\Models\File; use Fleetbase\Models\User; use Fleetbase\Models\UserDevice; @@ -41,6 +42,8 @@ */ class CustomerController extends Controller { + use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass; + /* ============================================================ | Public auth flows (API credential only, no Customer-Token) * ============================================================ */ @@ -144,7 +147,7 @@ public function create(CreateCustomerRequest $request) 'for' => 'fleetops_create_customer', 'meta->identity' => $identity, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($identity, $code)) { return response()->apiError('Invalid verification code provided.'); } @@ -312,6 +315,14 @@ protected function resolveCustomerPlace($input, Contact $contact, string $compan 'company_uuid' => $companyUuid, 'owner_uuid' => $contact->uuid, 'owner_type' => get_class($contact), + // `places.location` is a NOT NULL POINT column with no database default, + // and $allowed above is address-only — a caller cannot supply coordinates + // here. Without this the insert fails with SQLSTATE[HY000] 1364 ("Field + // 'location' doesn't have a default value"), turning a documented signup + // payload into a 500. Point(0, 0) is the same placeholder the geocoding + // helpers fall back to when an address cannot be resolved — see + // Place::getGoogleAddressArray and Place::findExistingSharedPlace. + 'location' => new SpatialPoint(0, 0), ], $attributes, )); @@ -417,7 +428,7 @@ public function verifyCode(Request $request) 'code' => $code, 'for' => $for, ]); - if (!$verificationCode) { + if (!$verificationCode && !$this->verificationBypassMatches($identity, $code)) { return response()->apiError('Invalid verification code.'); } @@ -503,7 +514,9 @@ public function resetPassword(Request $request) 'for' => 'fleetops_customer_password_reset', 'meta->identity' => $needle, ]); - if (!$verificationCode) { + // $needle, not $identity: the other verify paths normalise in place, so an + // allowlisted phone must be compared in the same normalised form here too. + if (!$verificationCode && !$this->verificationBypassMatches($needle, $code)) { return response()->apiError('Invalid reset code.'); } @@ -517,7 +530,10 @@ public function resetPassword(Request $request) $user->save(); // Invalidate all existing sessions for this user after a password reset. $this->deleteUserTokens($user); - $verificationCode->delete(); + // Null on the testing-bypass path — there is no row to consume. + if ($verificationCode) { + $verificationCode->delete(); + } return response()->json(['status' => 'ok']); } @@ -918,6 +934,36 @@ protected function generateSmsVerification(User $user, string $for, array $optio return VerificationCode::generateSmsVerificationFor($user, $for, $options); } + /** + * Whether the supplied code matches the configured testing bypass code. + * + * Three conditions, all required, mirroring the console equivalent in + * Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode: + * a bypass code must actually be configured, the app must not be running in + * production, and the comparison is constant-time. + * + * Fails safe by default: config/app.php resolves `env` to `production` when + * neither APP_ENV nor ENVIRONMENT is set, so an unconfigured install cannot + * be bypassed even accidentally. + * + * `!== null && !== ''` rather than `!empty()` — `!empty('0')` is false, so a + * configured bypass code of "0" would otherwise be silently ignored. + * + * Kept out of verificationCodeExists()/findVerificationCode() on purpose: + * those two are test seams that the controller contract tests override, so a + * policy living inside them would be stubbed away exactly where it matters. + */ + protected function verificationBypassMatches(?string $identity, ?string $code): bool + { + return static::reviewAccountBypassMatches( + 'fleetops.customers.verification_bypass_code', + 'fleetops.customers.review_accounts', + $identity, + $code, + 'fleetops-customer' + ); + } + protected function verificationCodeExists(array $attributes): bool { return VerificationCode::where($attributes)->exists(); diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index bc1ba8c3c..9446c245e 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -39,6 +39,8 @@ class DriverController extends Controller { + use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass; + /** * Creates a new Fleetbase Driver resource. * @@ -438,6 +440,15 @@ public function toggleOnline(string $id, Request $request) */ public function registerDevice(?string $id = null, ?Request $request = null) { + // Laravel does NOT inject a class-typed parameter that declares a default value — + // RouteDependencyResolverTrait skips it and the default (null) is used. So the + // router always called this with $request === null, which made + // POST /v1/drivers/{id}/register-device fail with "Call to a member function + // input() on null" and POST /v1/drivers/register-device answer 404 (the driver was + // looked up by a null user_uuid). The default has to stay, because the internal + // controller delegates to this method with an id only. + $request = $request ?? request(); + try { // With an id (…/{id}/register-device) look the driver up directly; without // one (…/register-device and the internal delegation) resolve the driver @@ -611,7 +622,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('fleetops.navigator.bypass_verification_code')) { + if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) { return response()->apiError('Invalid verification code!'); } @@ -960,7 +971,21 @@ protected function findDriver(string $id, array $with = []): Driver */ protected function currentDriver(?Request $request): Driver { - return Driver::where('user_uuid', optional(optional($request)->user())->uuid)->firstOrFail(); + // $request->user() is never populated on the public API: the `fleetbase.api` + // middleware calls Auth::setSession($apiCredential), which writes the session keys + // but leaves $login false, so no user resolver is ever bound. Reading it alone + // meant POST /v1/drivers/register-device looked a driver up by a null user_uuid + // and answered 404 for every consumer of the route. + // + // Auth::getUserFromSession() encodes this same order, but it also calls auth() and + // session()->has(), neither of which exists in the SQLite test harness — so the + // two sources are read directly here. + $user = optional($request)->user(); + if (!$user instanceof User) { + $user = User::where('uuid', session('user'))->first(); + } + + return Driver::where('user_uuid', optional($user)->uuid)->firstOrFail(); } protected function queryDrivers(Request $request) @@ -1035,6 +1060,36 @@ private static function getDriverCompanyFromUser(User $user): ?Company return $company; } + /** + * Whether the supplied code matches the configured testing bypass code. + * + * Three conditions, all required, mirroring the console equivalent in + * Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode: + * + * - a bypass code must actually be configured. The previous + * `$code !== config(...)` comparison meant that on a default install -- + * where the config resolves to null -- omitting `code` entirely made the + * check `null !== null`, i.e. false, so the guard never fired and any + * caller with a valid org API credential could mint a driver token for + * any driver in the install without a code at all; + * - the app must not be in production, so a code left set in a deployed + * .env cannot be used against a live fleet; + * - the comparison is constant-time. + * + * Shared with Internal\v1\DriverController so both verify-code paths cannot + * drift apart. + */ + public static function verificationBypassMatches(?string $identity, ?string $code): bool + { + return static::reviewAccountBypassMatches( + 'fleetops.navigator.bypass_verification_code', + 'fleetops.navigator.review_accounts', + $identity, + $code, + 'navigator' + ); + } + /** * Patches phone number with international code. */ diff --git a/server/src/Http/Controllers/Api/v1/GeofenceController.php b/server/src/Http/Controllers/Api/v1/GeofenceController.php index 591db6b6a..433fdd62f 100644 --- a/server/src/Http/Controllers/Api/v1/GeofenceController.php +++ b/server/src/Http/Controllers/Api/v1/GeofenceController.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Models\GeofenceEventLog; use Fleetbase\FleetOps\Support\Utils; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -201,13 +202,22 @@ public function dwellReport(Request $request): JsonResponse * * Returns the geofence event history for a specific driver. */ - public function driverHistory(Request $request, string $driverUuid): JsonResponse + public function driverHistory(Request $request, string $driverId): JsonResponse { $companyUuid = session('company'); $perPage = min((int) $request->input('per_page', 50), 200); + // Addressed by public_id, like every other public endpoint. The route parameter + // was named driverUuid and matched against driver_uuid directly, so this was the + // one place in the public API asking a caller for an internal identifier — which + // the API does not hand out in the first place, making it unusable. + $driver = $this->findDriverForHistory($driverId); + if (!$driver) { + return response()->json(['error' => 'Driver resource not found.'], 404); + } + $events = $this->geofenceEventLogQuery($companyUuid) - ->where('driver_uuid', $driverUuid) + ->where('driver_uuid', $driver->uuid) ->with(['driver.vehicle', 'vehicle', 'order']) ->orderBy('occurred_at', 'desc') ->paginate($perPage); @@ -217,6 +227,28 @@ public function driverHistory(Request $request, string $driverUuid): JsonRespons return response()->json($events); } + /** + * Resolve the driver a history request addresses. + * + * Public callers use the public_id, like every other v1 endpoint. The console reaches + * the same method through the internal route with a uuid, so that is accepted there + * and ONLY there — the public contract stays public_id-only rather than quietly + * taking either. + * + * A seam, like geofenceEventLogQuery above, so the contract tests can drive this + * without a database. + */ + protected function findDriverForHistory(string $driverId): ?Driver + { + $driver = Driver::where('public_id', $driverId)->first(); + + if (!$driver && \Fleetbase\Support\Http::isInternalRequest()) { + $driver = Driver::where('uuid', $driverId)->first(); + } + + return $driver; + } + protected function serializeEvent(GeofenceEventLog $event): array { $driver = $event->driver; diff --git a/server/src/Http/Controllers/Api/v1/NavigatorController.php b/server/src/Http/Controllers/Api/v1/NavigatorController.php index 7ac4530a3..a818a10f0 100644 --- a/server/src/Http/Controllers/Api/v1/NavigatorController.php +++ b/server/src/Http/Controllers/Api/v1/NavigatorController.php @@ -10,25 +10,21 @@ class NavigatorController extends Controller { /** - * Retrieve the driver onboard settings. + * Retrieve the driver onboard settings for an organization. * - * This method retrieves the driver onboard settings for the current company session. If no company session - * is found in the request, an error response is returned. The method retrieves the company ID from the session, - * then fetches the saved driver onboard settings. If settings for the current company are found, they are returned, - * otherwise, default settings are provided. - * - * @return JsonResponse - */ - - /** - * Retrieve driver onboard settings. + * The organization is resolved from its public id. An unknown public id is a client + * error, not a server error, so it answers 404 rather than dereferencing a null company. * * @return JsonResponse */ public function getDriverOnboardSettings($companyId) { - $company = $this->findCompanyByPublicId($companyId); - $driverOnboardSettings = $this->driverOnboardSetting($company->uuid); + $company = $this->findCompanyByPublicId($companyId); + if (!$company) { + return $this->errorResponse('Organization not found.', 404); + } + + $driverOnboardSettings = $this->driverOnboardSetting($company->uuid); if (!$driverOnboardSettings) { $driverOnboardSettings = []; } @@ -50,4 +46,9 @@ protected function jsonResponse(array $payload): JsonResponse { return response()->json($payload); } + + protected function errorResponse(string $message, int $statusCode): JsonResponse + { + return response()->error($message, $statusCode); + } } diff --git a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php index 001e535f2..d79658fcc 100644 --- a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +++ b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php @@ -200,7 +200,16 @@ protected function findQrModel(array $tables, array $where) } } - return Utils::findModel($tables, $where); + // No raw-row fallback. Utils::findModel() was called with the ARRAY of table names + // and, when nothing matched, passed that array to DB::table() — which stringified + // it to the literal table name "Array" and threw SQLSTATE[42S02]. Every + // unresolvable code became a 500 instead of the 400 this method's caller already + // handles. + // + // The fallback could not have helped anyway: qrModelResource() serializes through + // a typed resource keyed on the model class, and a raw stdClass row has none. The + // mapped models above are the endpoint's whole contract. + return null; } protected function qrModelResource($model) diff --git a/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php b/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php new file mode 100644 index 000000000..a60f0c87a --- /dev/null +++ b/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php @@ -0,0 +1,62 @@ + strtolower(trim((string) $account)), + (array) config($accountsKey, []) + ); + + if (!in_array(strtolower(trim($identity)), $accounts, true)) { + return false; + } + + // Constant-time, so a wrong code cannot be recovered by timing the response. + if (!hash_equals((string) $bypassCode, (string) $code)) { + return false; + } + + Log::warning('[Fleetbase] Verification bypass accepted for a review account.', [ + 'channel' => $channel, + 'identity' => $identity, + ]); + + return true; + } +} diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index bcaadae54..de272c6c1 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -665,7 +665,7 @@ public function verifyCode(Request $request) // Find and verify code $verificationCode = static::verificationCodeExists($user, $code, $for); - if (!$verificationCode && $code !== config('fleetops.navigator.bypass_verification_code')) { + if (!$verificationCode && !ApiDriverController::verificationBypassMatches($identity, $code)) { return response()->error('Invalid verification code!'); } diff --git a/server/src/Http/Filter/MaintenanceScheduleFilter.php b/server/src/Http/Filter/MaintenanceScheduleFilter.php new file mode 100644 index 000000000..c18f370a4 --- /dev/null +++ b/server/src/Http/Filter/MaintenanceScheduleFilter.php @@ -0,0 +1,40 @@ +builder->where('company_uuid', $this->session->get('company')); + } + + public function queryForPublic(): void + { + $this->queryForInternal(); + } + + public function subjectType(?string $subjectType): void + { + if (!$subjectType) { + return; + } + + if (!Str::contains($subjectType, ['\\', ':'])) { + $subjectType = 'fleet-ops:' . $subjectType; + } + + $this->builder->where('subject_type', Utils::getMutationType($subjectType)); + } + + public function subjectUuid(?string $subjectUuid): void + { + if ($subjectUuid) { + $this->builder->where('subject_uuid', $subjectUuid); + } + } +} diff --git a/server/src/Http/Requests/CreateCustomerRequest.php b/server/src/Http/Requests/CreateCustomerRequest.php index bd4265d80..761606858 100644 --- a/server/src/Http/Requests/CreateCustomerRequest.php +++ b/server/src/Http/Requests/CreateCustomerRequest.php @@ -23,7 +23,15 @@ public function rules(): array { return [ 'identity' => 'required|string', - 'code' => 'required|exists:verification_codes,code', + // Presence only — authorizing the code is the controller's job, and its + // check is strictly stronger: it matches code + for + meta->identity, + // whereas `exists:verification_codes,code` accepts any live code issued + // for any purpose to any user. The rule is also already unenforced on the + // proxy path, since verifyCode() with for=fleetops_create_customer calls + // create(CreateCustomerRequest::createFrom($request)), which never runs + // validateResolved(). Keeping it here only blocks the configured + // non-production testing bypass (fleetops.customers.verification_bypass_code). + 'code' => 'required|string', 'name' => 'required|string', 'password' => 'required|string|min:8', 'email' => [ diff --git a/server/src/Http/Requests/CreateFuelReportRequest.php b/server/src/Http/Requests/CreateFuelReportRequest.php index 33d707d19..cd9522072 100644 --- a/server/src/Http/Requests/CreateFuelReportRequest.php +++ b/server/src/Http/Requests/CreateFuelReportRequest.php @@ -19,10 +19,21 @@ public function authorize(): bool */ public function rules(): array { + // requiredIf(POST), not required: UpdateFuelReportRequest extends this class, so a + // flat `required` made every partial update fail — PUT /v1/fuel-reports/{id} with + // just {"status": "approved"} answered "The driver field is required." + // + // `driver` is the clearest case: the update action's $request->only() list does not + // even include it, so validation demanded a field the endpoint then discarded. + // CreateFuelTransactionRequest solves the same create/update inheritance with + // Rule::requiredIf(isMethod('POST')); a plain conditional is used here because + // illuminate/validation is not autoloadable in the package's test harness. + $requiredOnCreate = $this->isMethod('POST') ? ['required'] : ['sometimes']; + return [ - 'driver' => ['required'], - 'odometer' => ['required'], - 'volume' => ['required'], + 'driver' => $requiredOnCreate, + 'odometer' => $requiredOnCreate, + 'volume' => $requiredOnCreate, 'metric_unit' => ['nullable'], 'location' => ['nullable'], 'amount' => ['nullable'], diff --git a/server/src/Http/Requests/CreateFuelTransactionRequest.php b/server/src/Http/Requests/CreateFuelTransactionRequest.php index ca91ca6eb..bfe77aa1a 100644 --- a/server/src/Http/Requests/CreateFuelTransactionRequest.php +++ b/server/src/Http/Requests/CreateFuelTransactionRequest.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Requests; +use Fleetbase\FleetOps\Models\FuelProviderTransaction; use Fleetbase\Http\Requests\FleetbaseRequest; use Illuminate\Validation\Rule; @@ -16,7 +17,7 @@ public function rules(): array { return [ 'provider' => [Rule::requiredIf($this->isMethod('POST')), 'string'], - 'provider_transaction_id' => [Rule::requiredIf($this->isMethod('POST')), 'string'], + 'provider_transaction_id' => [Rule::requiredIf($this->isMethod('POST')), 'string', $this->uniqueProviderTransactionIdRule()], 'connection' => ['nullable', 'string'], 'fuel_report' => ['nullable', 'string'], 'vehicle' => ['nullable', 'string'], @@ -47,4 +48,66 @@ public function rules(): array 'meta' => ['nullable', 'array'], ]; } + + public function messages(): array + { + return [ + 'provider_transaction_id.unique' => 'A fuel transaction with this provider transaction id already exists for this provider.', + ]; + } + + /** + * Mirrors the fuel_provider_txn_company_provider_unique index. + * + * Provider transaction ids are natural idempotency keys, so a re-sent batch + * used to raise an unhandled UniqueConstraintViolationException — an HTTP 500 + * rather than a duplicate signal the client could act on. + * + * @return \Illuminate\Validation\Rules\Unique + */ + protected function uniqueProviderTransactionIdRule() + { + $provider = $this->resolveProvider(); + + $rule = Rule::unique('fuel_provider_transactions', 'provider_transaction_id') + ->where(function ($query) use ($provider) { + $query->where('company_uuid', session('company')); + $query->where('provider', $provider); + + return $query->whereNull('deleted_at'); + }); + + // On PUT the record re-sends its own provider_transaction_id. The v1 route + // parameter is a public_id (fuel_provider_transaction_xxxxx), not a uuid. + $id = $this->route('id'); + if (!$this->isMethod('POST') && filled($id)) { + $rule->ignore($id, 'public_id'); + } + + return $rule; + } + + /** + * The uniqueness scope is per provider, but `provider` is only required on + * POST — a PUT may change the transaction id while leaving the provider out of + * the body. Fall back to the stored value so the scope is never null, which + * would otherwise compare against the wrong set of rows. + */ + protected function resolveProvider(): ?string + { + if ($this->filled('provider')) { + return $this->input('provider'); + } + + $id = $this->route('id'); + if (blank($id)) { + return null; + } + + return FuelProviderTransaction::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('public_id', $id)->orWhere('uuid', $id); + }) + ->value('provider'); + } } diff --git a/server/src/Http/Requests/CreatePartRequest.php b/server/src/Http/Requests/CreatePartRequest.php index a3a197af7..fc600d271 100644 --- a/server/src/Http/Requests/CreatePartRequest.php +++ b/server/src/Http/Requests/CreatePartRequest.php @@ -15,7 +15,7 @@ public function authorize(): bool public function rules(): array { return [ - 'sku' => ['nullable', 'string'], + 'sku' => ['nullable', 'string', $this->uniqueSkuRule()], 'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'], 'manufacturer' => ['nullable', 'string'], 'model' => ['nullable', 'string'], @@ -37,4 +37,42 @@ public function rules(): array 'meta' => ['nullable', 'array'], ]; } + + public function messages(): array + { + return [ + 'sku.unique' => 'A part with this SKU already exists.', + ]; + } + + /** + * Mirrors the parts_company_uuid_active_sku_unique index. + * + * Without this rule a duplicate SKU reached the driver and came back as an + * unhandled UniqueConstraintViolationException — an HTTP 500 with no field + * attribution, since public v1 controllers have no QueryException catch. + * + * `nullable` runs first, so this never fires on a null SKU; that matches the + * nullable column and MySQL's tolerance of repeated NULLs in a unique index. + * + * @return \Illuminate\Validation\Rules\Unique + */ + protected function uniqueSkuRule() + { + $rule = Rule::unique('parts', 'sku') + ->where(function ($query) { + $query->where('company_uuid', session('company')); + + return $query->whereNull('deleted_at'); + }); + + // On PUT the record re-sends its own SKU, so it must not collide with + // itself. The v1 route parameter is a public_id (part_xxxxx), not a uuid. + $id = $this->route('id'); + if (!$this->isMethod('POST') && filled($id)) { + $rule->ignore($id, 'public_id'); + } + + return $rule; + } } diff --git a/server/src/Http/Resources/v1/TrackingNumber.php b/server/src/Http/Resources/v1/TrackingNumber.php index 0d44e9bad..bed9b0d5d 100644 --- a/server/src/Http/Resources/v1/TrackingNumber.php +++ b/server/src/Http/Resources/v1/TrackingNumber.php @@ -30,6 +30,17 @@ public function toArray($request) 'status' => $this->last_status, 'status_code' => $this->last_status_code, 'qr_code' => $this->qr_code, + // The raw text encoded inside qr_code, exposed ONLY in debug mode. + // + // qr_code is a base64 PNG generated from owner_uuid (TrackingNumber::newBarcode), + // and the endpoints that consume a scanned code match on that uuid. A client + // scans the image to obtain it; an automated contract run cannot, so debug + // builds publish the value beside the image rather than the API growing a + // decode endpoint or handing out uuids generally. + // + // `when()` omits the key entirely when false — it is absent, not null — so a + // production response is byte-identical to before. + 'qr_code_content' => $this->when(static::exposesQrCodeContent(), fn () => $this->owner_uuid), 'barcode' => $this->barcode, 'url' => Utils::consoleUrl('track-order', ['order' => $this->tracking_number]), 'type' => Utils::getTypeFromClassName($this->owner_type), @@ -38,6 +49,28 @@ public function toArray($request) ]; } + /** + * Whether the QR code's decoded content may be published. + * + * Debug mode only, and fails closed: any failure to determine the debug state — no + * container, an application without the accessor — answers false rather than + * defaulting to exposure. + */ + protected static function exposesQrCodeContent(): bool + { + try { + $app = app(); + + if (!is_object($app) || !method_exists($app, 'hasDebugModeEnabled')) { + return false; + } + + return (bool) $app->hasDebugModeEnabled(); + } catch (\Throwable $e) { + return false; + } + } + /** * Transform the resource into an webhook payload. * diff --git a/server/src/Models/FuelReport.php b/server/src/Models/FuelReport.php index fc0118412..8c7b0b669 100644 --- a/server/src/Models/FuelReport.php +++ b/server/src/Models/FuelReport.php @@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Casts\Point; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; +use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint; use Fleetbase\Models\Model; use Fleetbase\Models\User; use Fleetbase\Traits\HasApiModelBehavior; @@ -149,6 +150,26 @@ public function reporter() return $this->belongsTo(User::class, 'reported_by_uuid'); } + /** + * Bootstrap the model. + * + * `location` is declared nullable in CreateFuelReportRequest but the column is + * POINT NOT NULL, so a report created without one failed outright with + * SQLSTATE[HY000] 1364 "Field 'location' doesn't have a default value" — a 500 for + * a request the validator had just accepted. Mirrors Device and Sensor, which + * default their spatial columns the same way. + */ + protected static function boot() + { + parent::boot(); + + static::creating(function (self $fuelReport) { + if (empty($fuelReport->location)) { + $fuelReport->location = new SpatialPoint(0, 0); + } + }); + } + /** * Get the driver's name assigned to vehicle. * diff --git a/server/src/Models/Sensor.php b/server/src/Models/Sensor.php index efa4151d4..8b20638ba 100644 --- a/server/src/Models/Sensor.php +++ b/server/src/Models/Sensor.php @@ -147,6 +147,30 @@ class Sensor extends Model */ protected $spatialFields = ['last_position']; + /** + * Bootstrap the model. + * + * The same migration that made devices.last_position NOT NULL did it to + * sensors.last_position too, but only Device got a default. Creating a sensor + * without a position therefore failed outright: + * + * SQLSTATE[HY000]: General error: 1364 Field 'last_position' doesn't have a + * default value + * + * Position is not something a caller has when registering a sensor, so default + * it to POINT(0,0) on create, exactly as Device does. + */ + protected static function boot() + { + parent::boot(); + + static::creating(function (self $sensor) { + if (empty($sensor->last_position)) { + $sensor->last_position = new SpatialPoint(0, 0); + } + }); + } + /** * The attributes that should be cast to native types. * diff --git a/server/src/Support/FuelProviders/FuelProviderService.php b/server/src/Support/FuelProviders/FuelProviderService.php index 031057915..93787c6e7 100644 --- a/server/src/Support/FuelProviders/FuelProviderService.php +++ b/server/src/Support/FuelProviders/FuelProviderService.php @@ -173,8 +173,17 @@ public function ingestTransaction(FuelProviderConnection $connection, array $pay return DB::transaction(function () use ($connection, $payload) { $provider = $payload['provider'] ?? $connection->provider; $providerTransactionId = $payload['provider_transaction_id']; + // The match must include company_uuid. Provider transaction ids are only + // unique within the account they were issued for, so keying on + // (provider, provider_transaction_id) alone let one company's sync find + // and overwrite another company's transaction — reassigning its + // company_uuid. Mirrors fuel_provider_txn_company_provider_unique. $transaction = FuelProviderTransaction::updateOrCreate( - ['provider' => $provider, 'provider_transaction_id' => $providerTransactionId], + [ + 'company_uuid' => $connection->company_uuid, + 'provider' => $provider, + 'provider_transaction_id' => $providerTransactionId, + ], array_merge($payload, [ 'company_uuid' => $connection->company_uuid, 'fuel_provider_connection_uuid' => $connection->uuid, diff --git a/server/src/routes.php b/server/src/routes.php index db2c69d09..1ef332181 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -237,7 +237,7 @@ function ($router) { $router->get('events', 'GeofenceController@events'); $router->get('inventory', 'GeofenceController@inventory'); $router->get('dwell-report', 'GeofenceController@dwellReport'); - $router->get('driver/{driverUuid}/history', 'GeofenceController@driverHistory'); + $router->get('driver/{driverId}/history', 'GeofenceController@driverHistory'); }); // service-rates routes $router->group(['prefix' => 'service-rates'], function () use ($router) { diff --git a/server/tests/ApiCustomerControllerContractsTest.php b/server/tests/ApiCustomerControllerContractsTest.php index e0c9da3e2..ea1b7202b 100644 --- a/server/tests/ApiCustomerControllerContractsTest.php +++ b/server/tests/ApiCustomerControllerContractsTest.php @@ -960,3 +960,171 @@ function fleetopsApiCustomerJson($response): array 'name' => 'explode', ]))))->toBe(['error' => 'update failed']); }); + +/** + * Run a callback with an app container that supports environment(). + * + * The harness binds a bare Illuminate\Container\Container, which has no + * environment() — so CustomerController::verificationBypassMatches fatals with + * "Call to undefined method" without this. Mirrors the swap in + * NotificationAndMailContractsTest, but carries every existing binding across so + * the controller can still resolve config/request/db. + */ +function fleetopsApiCustomerWithEnvironment(string $environment, callable $callback): mixed +{ + $previousApp = Illuminate\Container\Container::getInstance(); + $app = new class extends Illuminate\Container\Container { + public string $fleetopsEnvironment = 'testing'; + + public function environment(...$environments) + { + if (empty($environments)) { + return $this->fleetopsEnvironment; + } + + $environments = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array($this->fleetopsEnvironment, $environments, true); + } + + public function hasDebugModeEnabled() + { + return false; + } + }; + $app->fleetopsEnvironment = $environment; + + $reflection = new ReflectionClass(Illuminate\Container\Container::class); + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'scopedInstances'] as $property) { + if (!$reflection->hasProperty($property)) { + continue; + } + $handle = $reflection->getProperty($property); + $handle->setAccessible(true); + $handle->setValue($app, $handle->getValue($previousApp)); + } + + Illuminate\Container\Container::setInstance($app); + + try { + return $callback(); + } finally { + Illuminate\Container\Container::setInstance($previousApp); + } +} + +test('api customer controller ignores the verification bypass unless it is configured', function () { + // Unset and empty-string are both inert. Without this the bypass would be a + // standing hole in every default install, which is the entire risk of shipping one. + foreach ([null, ''] as $bypassCode) { + config(['fleetops.customers.verification_bypass_code' => $bypassCode]); + + fleetopsApiCustomerWithEnvironment('local', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + + expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']) + ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ]))))->toBe(['error' => 'Invalid verification code.']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['error' => 'Invalid reset code.']); + }); + } + + config(['fleetops.customers.verification_bypass_code' => null]); +}); + +test('api customer controller accepts a configured verification bypass for a listed review account', function () { + config([ + 'fleetops.customers.verification_bypass_code' => '000000', + 'fleetops.customers.review_accounts' => ['jane@example.test'], + ]); + + // Deliberately production. App store reviewers test a release build against + // production, so refusing the bypass there made review impossible — that is the + // behaviour this replaces. Safety now comes from the allowlist, not the environment. + fleetopsApiCustomerWithEnvironment('production', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + // No VerificationCode row on the bypass path — resetPassword must not fatal + // calling ->delete() on null, and must still revoke existing sessions. + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + // A non-matching code is still rejected while the bypass is live. + $wrong = fleetopsApiCustomerController(); + $wrong->verificationExists = false; + + expect($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + 'name' => 'Jane', + 'password' => 'password-secret', + ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token']) + ->and($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ])))->toMatchArray(['resource' => 'customer', 'token' => 'plain-token']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['status' => 'ok']) + ->and($reset->genericUser->tokensDeleted)->toBeTrue() + ->and(fleetopsApiCustomerJson($wrong->create(new CreateCustomerRequest([ + 'code' => '999999', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']); + }); + + config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]); +}); + +test('api customer controller refuses the verification bypass for an identity that is not listed', function () { + // The point of the allowlist: holding the code is not sufficient. Previously + // anyone who learned it could authenticate as any customer. + foreach ([['someone-else@example.test'], []] as $reviewAccounts) { + config([ + 'fleetops.customers.verification_bypass_code' => '000000', + 'fleetops.customers.review_accounts' => $reviewAccounts, + ]); + + fleetopsApiCustomerWithEnvironment('local', function () { + $create = fleetopsApiCustomerController(); + $create->verificationExists = false; + $verify = fleetopsApiCustomerController(); + $verify->verificationExists = false; + $reset = fleetopsApiCustomerController(); + $reset->verificationCode = null; + + expect(fleetopsApiCustomerJson($create->create(new CreateCustomerRequest([ + 'code' => '000000', + 'identity' => 'jane@example.test', + ]))))->toBe(['error' => 'Invalid verification code provided.']) + ->and(fleetopsApiCustomerJson($verify->verifyCode(Request::create('/v1/customers/verify-code', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + ]))))->toBe(['error' => 'Invalid verification code.']) + ->and(fleetopsApiCustomerJson($reset->resetPassword(Request::create('/v1/customers/reset-password', 'POST', [ + 'identity' => 'jane@example.test', + 'code' => '000000', + 'password' => 'password-secret', + ]))))->toBe(['error' => 'Invalid reset code.']); + }); + } + + config(['fleetops.customers.verification_bypass_code' => null, 'fleetops.customers.review_accounts' => []]); +}); diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php index ee3c1a96a..b11dc3754 100644 --- a/server/tests/ApiDriverControllerContractsTest.php +++ b/server/tests/ApiDriverControllerContractsTest.php @@ -550,6 +550,38 @@ public function or(array $keys, mixed $default = null): mixed ]); }); +test('api driver controller falls back to the container request when the router injects null', function () { + // Laravel's ResolvesRouteDependencies::transformDependency() returns null — it does + // NOT resolve from the container — for any class-typed parameter that declares a + // default value. registerDevice() has to declare one, because the internal controller + // delegates with an id only, so every routed call arrived with $request === null. + // Every other test here passes a request explicitly, which is why they stayed green + // while POST /v1/drivers/{id}/register-device answered 500. + $driver = new FleetOpsApiDriverFake(); + $driver->setRawAttributes([ + 'uuid' => 'driver-uuid', + 'public_id' => 'driver_public', + 'user_uuid' => 'user-uuid', + ], true); + + $controller = new FleetOpsApiDriverControllerProbe(); + $controller->driver = $driver; + + $bound = new FleetOpsApiDriverRegisterDeviceRequest(['token' => 'push-token', 'platform' => 'android']); + app()->instance('request', $bound); + + expect($controller->registerDevice('driver_public', null))->toBe([ + 'json' => ['device' => 'device_public'], + 'status' => 200, + ]) + ->and($controller->deviceCreates)->toBe([ + [ + ['token' => 'push-token', 'platform' => 'android'], + ['user_uuid' => 'user-uuid', 'platform' => 'android', 'token' => 'push-token', 'status' => 'active'], + ], + ]); +}); + test('api driver controller reports missing driver branches', function () { $controller = new FleetOpsApiDriverControllerProbe(); $controller->driverNotFound = true; diff --git a/server/tests/CompactResourceSerializationTest.php b/server/tests/CompactResourceSerializationTest.php index c8ed76d09..b482a8c5f 100644 --- a/server/tests/CompactResourceSerializationTest.php +++ b/server/tests/CompactResourceSerializationTest.php @@ -2697,6 +2697,10 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = 'scheduled_at' => '2026-07-30 09:00:00', ]); + // The decoded QR content is a debug-only field and must never ride along on a webhook, + // which is a separate literal payload rather than a filtered toArray(). + expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse(); + expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([ 'id' => 'fixture_public', 'tracking_number' => 'TN-ZERO', @@ -2743,3 +2747,92 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = 'meta' => ['sequence' => 2], ]); }); + +test('tracking number resource publishes the qr code content only in debug mode', function () { + // qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a + // scanned code match on that uuid. Debug builds publish the value beside the image so + // an automated client can follow the flow without decoding a PNG; production must not. + $trackingNumber = fleetopsCompactResourceFixture([ + 'tracking_number' => 'TN-DEBUG', + 'owner_uuid' => 'owner-uuid-under-the-qr', + 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'region' => 'sg', + 'qr_code' => 'qr-data', + 'barcode' => 'barcode-data', + 'last_status' => 'created', + 'last_status_code' => 'CREATED', + ]); + $request = Request::create('/v1/tracking-numbers', 'GET'); + + // The full resource builds a console url, which needs app()->environment(). The bare + // container this suite runs on has no such method, which is why the tests above only + // cover the index resource and the webhook payload. + $previousContainer = Illuminate\Container\Container::getInstance(); + $container = new class extends Illuminate\Container\Container { + public bool $debugMode = false; + + public function environment(...$environments) + { + return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false; + } + + // Stands in for the framework's Application::hasDebugModeEnabled(). + public function hasDebugModeEnabled() + { + return $this->debugMode; + } + }; + $container->instance('config', new Illuminate\Config\Repository([ + 'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]], + ])); + Illuminate\Container\Container::setInstance($container); + + // Drive the REAL accessor through the container rather than overriding it, so the + // debug check itself is exercised and not just the resource's use of it. + $container->debugMode = true; + $withDebug = new TrackingNumberResource($trackingNumber); + + try { + $debugPayload = $withDebug->resolve($request); + + $container->debugMode = false; + $productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request); + } finally { + Illuminate\Container\Container::setInstance($previousContainer); + } + + // Present and exactly the value the QR was generated from — not a public id. + expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr') + ->and($debugPayload['qr_code'])->toBe('qr-data') + // Absent, not null: `when()` drops the key entirely, so a production response is + // byte-identical to one from before the field existed. + ->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse() + ->and($productionPayload['qr_code'])->toBe('qr-data'); +}); + +test('tracking number resource fails closed when the debug state cannot be determined', function () { + // Any failure to resolve the debug state must answer false rather than defaulting to + // exposure. The real accessor is exercised here with no usable application bound. + $expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent'); + $expose->setAccessible(true); + + $previous = Illuminate\Container\Container::getInstance(); + + try { + // A container that is not an Application has no hasDebugModeEnabled(). + Illuminate\Container\Container::setInstance(new Illuminate\Container\Container()); + expect($expose->invoke(null))->toBeFalse(); + + // And an accessor that throws must also answer false rather than propagating — + // a resource must not be able to fail serialization over a debug check. + Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container { + public function hasDebugModeEnabled() + { + throw new RuntimeException('debug state unavailable'); + } + }); + expect($expose->invoke(null))->toBeFalse(); + } finally { + Illuminate\Container\Container::setInstance($previous); + } +}); diff --git a/server/tests/CustomerEndpointTest.php b/server/tests/CustomerEndpointTest.php index ce9610526..38670211b 100644 --- a/server/tests/CustomerEndpointTest.php +++ b/server/tests/CustomerEndpointTest.php @@ -231,8 +231,11 @@ function fleetopsCustomerEndpointJson(JsonResponse $response): array $create = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/CreateCustomerRequest.php'); $verify = file_get_contents(dirname(__DIR__) . '/src/Http/Requests/VerifyCreateCustomerRequest.php'); + // These match against source text, so they have to be updated whenever the rule + // strings change. `code` is presence-only by design — the controller's own check + // (code + for + meta->identity) is strictly stronger than exists:verification_codes. expect($create) - ->toContain("'code' => 'required|exists:verification_codes,code'") + ->toContain("'code' => 'required|string'") ->toContain("'password' => 'required|string|min:8'") ->and($verify)->toContain("'mode' => 'required|in:email,sms'"); }); diff --git a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php index e917ac494..6168cf7e9 100644 --- a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php @@ -84,8 +84,59 @@ public function send($mailable) } } +/** + * Swap in a container that answers environment(). + * + * verificationBypassMatches() asks app()->environment('production'), which the bare + * Illuminate\Container\Container used by this harness does not implement. Mirrors + * fleetopsCustomerHelperContainer() in CustomerControllerHelperSeamsTest. + */ +function fleetopsDriverAuthContainer(): void +{ + $current = Illuminate\Container\Container::getInstance(); + if (method_exists($current, 'hasDebugModeEnabled')) { + return; + } + + $replacement = new class extends Illuminate\Container\Container { + public function environment(...$environments) + { + if (empty($environments)) { + return 'testing'; + } + + $checks = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array('testing', $checks, true); + } + + public function hasDebugModeEnabled() + { + return true; + } + }; + + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) { + if (!property_exists(Illuminate\Container\Container::class, $property)) { + continue; + } + $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property); + $reflection->setAccessible(true); + if ($reflection->isInitialized($current)) { + $reflection->setValue($replacement, $reflection->getValue($current)); + } + } + + Illuminate\Container\Container::setInstance($replacement); + Illuminate\Support\Facades\Facade::setFacadeApplication($replacement); +} + function fleetopsDriverAuthBoot(): SQLiteConnection { + // Must run before the app()->instance() calls below, so those bindings land on + // the replacement container rather than the one it supersedes. + fleetopsDriverAuthContainer(); + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); $resolver->setDefaultConnection('mysql'); @@ -231,6 +282,7 @@ public function __call($method, $arguments) // Bypass code from config authenticates without a stored code config()->set('fleetops.navigator.bypass_verification_code', '777777'); + config()->set('fleetops.navigator.review_accounts', ['driver@example.test']); $bypassed = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '777777'])); expect($bypassed)->toBeInstanceOf(DriverResource::class); @@ -346,6 +398,7 @@ public function create(Request $request) // A bypass code authenticates without a stored verification code, so the // flow reaches token issuance; without the token table that send fails config()->set('fleetops.navigator.bypass_verification_code', '777777'); + config()->set('fleetops.navigator.review_accounts', ['driver@example.test']); $connection->getSchemaBuilder()->drop('personal_access_tokens'); $response = $controller->verifyCode(Request::create('/x', 'POST', [ @@ -400,6 +453,7 @@ public function __call($method, $arguments) } config()->set('fleetops.navigator.bypass_verification_code', '777777'); + config()->set('fleetops.navigator.review_accounts', ['driver@example.test']); $response = $controller->verifyCode(Request::create('/x', 'POST', [ 'identity' => 'driver@example.test', 'code' => '777777', @@ -416,14 +470,16 @@ public function __call($method, $arguments) $connection->table('users')->insert(['uuid' => 'user-current-1', 'public_id' => 'user_current1', 'company_uuid' => 'company-1', 'name' => 'Current Driver', 'email' => 'current@example.test', 'type' => 'driver']); $connection->table('drivers')->insert(['uuid' => 'driver-current-1', 'public_id' => 'driver_current1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-current-1']); - // registerDevice without an id resolves the driver from the request user + // registerDevice without an id resolves the driver from the authenticated user // rather than a route parameter, so exercise the real seam here — the // contract probe overrides it and never runs this body $currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver'); $currentDriver->setAccessible(true); $request = Request::create('/v1/drivers/register-device', 'POST'); - $request->setUserResolver(fn () => $connection->table('users')->where('uuid', 'user-current-1')->first()); + // Auth::getUserFromSession only accepts a User model from the resolver — anything + // else falls through to the session, so this must be a real model, not a row object. + $request->setUserResolver(fn () => User::query()->where('uuid', 'user-current-1')->first()); $resolved = $currentDriver->invoke(new DriverController(), $request); @@ -431,8 +487,31 @@ public function __call($method, $arguments) ->and($resolved->uuid)->toBe('driver-current-1'); }); +test('current driver resolves from the session when the request has no user resolver', function () { + $connection = fleetopsDriverAuthBoot(); + $connection->table('users')->insert(['uuid' => 'user-session-1', 'public_id' => 'user_session1', 'company_uuid' => 'company-1', 'name' => 'Session Driver', 'email' => 'session@example.test', 'type' => 'driver']); + $connection->table('drivers')->insert(['uuid' => 'driver-session-1', 'public_id' => 'driver_session1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-session-1']); + + // This is the production path for the public API. `fleetbase.api` authenticates with + // Auth::setSession($apiCredential), which writes session('user') but does NOT bind a + // user resolver — so reading $request->user() directly resolved nothing and + // POST /v1/drivers/register-device answered 404 for every consumer. + session(['user' => 'user-session-1']); + + $currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver'); + $currentDriver->setAccessible(true); + + $resolved = $currentDriver->invoke(new DriverController(), Request::create('/v1/drivers/register-device', 'POST')); + + expect($resolved)->toBeInstanceOf(Driver::class) + ->and($resolved->uuid)->toBe('driver-session-1'); +}); + test('current driver fails when the request has no matching driver', function () { fleetopsDriverAuthBoot(); + // Explicit, because the session survives between tests and a leftover `user` key + // would silently resolve a driver and make this assertion vacuous. + session(['user' => null]); $currentDriver = new ReflectionMethod(DriverController::class, 'currentDriver'); $currentDriver->setAccessible(true); diff --git a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php index 279459216..a1b90fd94 100644 --- a/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +++ b/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php @@ -1,6 +1,7 @@ historyDriverLookups[] = $driverId; + + return $this->historyDriver; + } + protected function geofenceEventLogQuery(?string $companyUuid): mixed { $query = $this->nextEventQuery ?? new FleetOpsGeofenceQueryFake($companyUuid); @@ -351,10 +362,17 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog 'per_page' => 75, ]); - $controller->driverHistory($request, 'driver-9'); + // The endpoint is addressed by public_id and resolves the driver itself; the query + // still filters on the driver's uuid. + $historyDriver = new Fleetbase\FleetOps\Models\Driver(); + $historyDriver->setRawAttributes(['uuid' => 'driver-9', 'public_id' => 'driver_public9'], true); + $controller->historyDriver = $historyDriver; + + $controller->driverHistory($request, 'driver_public9'); $query = $controller->eventQueries[0]; - expect($query->companyUuid)->toBe('company-4') + expect($controller->historyDriverLookups)->toBe(['driver_public9']) + ->and($query->companyUuid)->toBe('company-4') ->and($query->calls)->toContain( ['where', 'driver_uuid', 'driver-9'], ['with', ['driver.vehicle', 'vehicle', 'order']], @@ -362,3 +380,82 @@ function fleetopsGeofenceEvent(array $attributes): GeofenceEventLog ['paginate', 75], ); }); + +test('driver history answers 404 when the driver does not resolve', function () { + // The endpoint's own not-found branch: findDriverForHistory returns null and the + // request must not proceed to a query keyed on nothing. + $controller = new FleetOpsGeofenceControllerFake(); + $controller->historyDriver = null; + + $response = $controller->driverHistory(Request::create('/geofences/driver/driver_missing/history', 'GET'), 'driver_missing'); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Driver resource not found.']) + ->and($controller->historyDriverLookups)->toBe(['driver_missing']) + // and no event query was ever built + ->and($controller->eventQueries)->toBe([]); +}); + +test('driver history resolves by public id, and by uuid only for internal requests', function () { + // The contract tests above stub findDriverForHistory(), so the real lookup needs its + // own coverage. It is the whole point of the change: the public endpoint used to take + // a uuid, which the public API never issues. + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + + $schema = $connection->getSchemaBuilder(); + // Driver carries a global scope requiring a related user, so both tables are needed. + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + $schema->create('drivers', 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->timestamp('deleted_at')->nullable(); + $table->timestamps(); + }); + $connection->table('users')->insert(['uuid' => 'user-uuid-1']); + $connection->table('drivers')->insert([ + 'uuid' => 'driver-uuid-1', + 'public_id' => 'driver_public1', + 'user_uuid' => 'user-uuid-1', + ]); + + $resolve = new ReflectionMethod(GeofenceController::class, 'findDriverForHistory'); + $resolve->setAccessible(true); + $controller = new GeofenceController(); + + $byPublicId = $resolve->invoke($controller, 'driver_public1'); + // A uuid is NOT accepted on a public request — that is the contract this restores. + $byUuidPublic = $resolve->invoke($controller, 'driver-uuid-1'); + $missing = $resolve->invoke($controller, 'driver_nope'); + + expect($byPublicId)->toBeInstanceOf(Driver::class) + ->and($byPublicId->uuid)->toBe('driver-uuid-1') + ->and($byUuidPublic)->toBeNull() + ->and($missing)->toBeNull(); + + // The console reaches the same method through the internal route with a uuid. + // Http::isInternalRequest() decides from the ROUTE's uri — any route with an "int" + // segment — so the request has to carry one, not a header. + $internalRequest = Request::create('/fleet-ops/int/v1/geofences/driver/driver-uuid-1/history', 'GET'); + $internalRequest->setRouteResolver(fn () => new Illuminate\Routing\Route( + ['GET'], + 'fleet-ops/int/v1/geofences/driver/{driverUuid}/history', + ['uses' => fn () => null] + )); + app()->instance('request', $internalRequest); + + $byUuidInternal = $resolve->invoke($controller, 'driver-uuid-1'); + + expect($byUuidInternal)->toBeInstanceOf(Driver::class) + ->and($byUuidInternal->public_id)->toBe('driver_public1'); +}); diff --git a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php index ebe98d90a..80baaba8a 100644 --- a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +++ b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php @@ -138,8 +138,14 @@ public function __call($method, $arguments) expect($qrModel)->not->toBeNull() ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class); - // Tables with no eloquent mapping are skipped and fall back to a raw row - // lookup, so the record still resolves but arrives unhydrated + // A table with no eloquent mapping resolves to null rather than a raw row. + // + // The raw-row fallback was removed: it passed the ARRAY of table names to + // DB::table(), which stringified it to the literal table "Array" and threw + // SQLSTATE[42S02], so every unresolvable QR code answered 500 instead of the 400 the + // caller already handles. It could not have helped either way — qrModelResource() + // serializes through a typed resource keyed on the model class, and a raw stdClass + // row has none. $connection->table('tracking_statuses')->insert([ 'uuid' => '44444444-4444-4444-8444-444444444403', 'public_id' => 'tracking_status_tnhelper1', @@ -147,8 +153,8 @@ public function __call($method, $arguments) 'status' => 'Dispatched', ]); - $unmapped = $helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']); - expect($unmapped)->not->toBeNull() - ->and($unmapped)->not->toBeInstanceOf(EloquentModel::class) - ->and($unmapped->uuid)->toBe('44444444-4444-4444-8444-444444444403'); + expect($helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']))->toBeNull() + // And a mapped table that simply matches nothing is also null, which is the path + // an unknown or unresolved code takes. + ->and($helper('findQrModel', ['entities', 'orders'], ['uuid' => '{{qr_code}}']))->toBeNull(); }); diff --git a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php index da993ef18..0d9cc86da 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php @@ -307,6 +307,11 @@ public function __construct() public static function resetProbe(): void { + // verifyCode() defers to DriverController::verificationBypassMatches(), which + // asks app()->environment('production'). The bare container this harness binds + // has no such method, so swap it before any verify-code test runs. + fleetopsInternalDriverAuthContainer(); + static::$loginUser = null; static::$verificationUser = null; static::$loginDriver = null; @@ -531,6 +536,53 @@ function fleetopsInternalDriverExportSelections(DriverExport $export): array return $property->getValue($export); } +/** + * Swap in a container that answers environment(). + * + * Mirrors fleetopsCustomerHelperContainer() in CustomerControllerHelperSeamsTest and + * fleetopsDriverAuthContainer() in Api/DriverControllerAuthFlowsTest. Idempotent, so + * resetProbe() can call it per test. + */ +function fleetopsInternalDriverAuthContainer(): void +{ + $current = Illuminate\Container\Container::getInstance(); + if (method_exists($current, 'hasDebugModeEnabled')) { + return; + } + + $replacement = new class extends Illuminate\Container\Container { + public function environment(...$environments) + { + if (empty($environments)) { + return 'testing'; + } + + $checks = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array('testing', $checks, true); + } + + public function hasDebugModeEnabled() + { + return true; + } + }; + + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) { + if (!property_exists(Illuminate\Container\Container::class, $property)) { + continue; + } + $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property); + $reflection->setAccessible(true); + if ($reflection->isInitialized($current)) { + $reflection->setValue($replacement, $reflection->getValue($current)); + } + } + + Illuminate\Container\Container::setInstance($replacement); + Illuminate\Support\Facades\Facade::setFacadeApplication($replacement); +} + function fleetopsInternalDriverUseHelperDatabase(): SQLiteConnection { $connection = new SQLiteConnection(new PDO('sqlite::memory:')); @@ -993,6 +1045,9 @@ public function getCurrentOrder(): ?Order test('internal driver controller verify code covers missing user invalid code and missing driver branches', function () { app('config')->set('fleetops.navigator.bypass_verification_code', '000000'); + // The bypass is only honoured for a listed identity. Both forms because + // verifyCode normalises a non-email identity through static::phone(). + app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']); FleetOpsInternalDriverAuthControllerProbe::resetProbe(); $controller = new FleetOpsInternalDriverAuthControllerProbe(); @@ -1035,6 +1090,9 @@ public function getCurrentOrder(): ?Order test('internal driver controller verify code returns driver resource and handles token errors', function () { app('config')->set('fleetops.navigator.bypass_verification_code', '000000'); + // The bypass is only honoured for a listed identity. Both forms because + // verifyCode normalises a non-email identity through static::phone(). + app('config')->set('fleetops.navigator.review_accounts', ['15551234567', '+15551234567', 'driver@example.test']); FleetOpsInternalDriverAuthControllerProbe::resetProbe(); FleetOpsInternalDriverAuthControllerProbe::$verificationUser = fleetopsInternalDriverAuthUser(); diff --git a/server/tests/FuelProviderTransactionControllerContractsTest.php b/server/tests/FuelProviderTransactionControllerContractsTest.php index 800da1ce7..fdd1e25a7 100644 --- a/server/tests/FuelProviderTransactionControllerContractsTest.php +++ b/server/tests/FuelProviderTransactionControllerContractsTest.php @@ -178,4 +178,21 @@ function fleetopsFuelProviderTransactionController(FleetOpsFuelProviderTransacti expect($reflection->invoke($controller, 'fpt-real-1')->public_id)->toBe('fuel_provider_transaction_real1') ->and($reflection->invoke($controller, 'fuel_provider_transaction_real1')->uuid)->toBe('fpt-real-1'); + + // resolveProvider() falls back to the stored provider when a PUT omits it, so the + // uniqueness scope is never null — a null scope would compare the transaction id + // against the wrong set of rows. Needs a real connection, which is why this lives + // here rather than in RequestContractsTest. + $resolveProvider = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::class, 'resolveProvider'); + $resolveProvider->setAccessible(true); + + $withRouteId = Fleetbase\FleetOps\Http\Requests\CreateFuelTransactionRequest::create('/fleetops-test', 'PUT') + ->setRouteResolver(fn () => new class { + public function parameter($key, $default = null) + { + return $key === 'id' ? 'fuel_provider_transaction_real1' : $default; + } + }); + + expect($resolveProvider->invoke($withRouteId))->toBe('petroapp'); }); diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index e0fcf72d2..d9c8749d6 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -76,6 +76,18 @@ public function whereNull($column): self return $this; } + /** + * Recorded so tests can assert the ignore-self clause on update + * requests, including which column it matches on — the v1 routes + * pass a public_id, not a uuid. + */ + public function ignore($id, $idColumn = null): self + { + $this->constraints[] = ['ignore', $id, $idColumn]; + + return $this; + } + public function __toString(): string { return $this->rule; @@ -245,11 +257,16 @@ protected function canUpdateDriver(): bool }); test('fuel transaction request requires provider identifiers on create', function () { + // The harness `session()` stand-in is a process-wide static, so seed it + // here rather than relying on an earlier test in the file having done so. + session(['company' => 'company-uuid']); + bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']); + $createRules = requestRules(CreateFuelTransactionRequest::class); $patchRules = requestRules(CreateFuelTransactionRequest::class, 'PATCH'); expect(ruleStrings($createRules['provider']))->toContain('required', 'string') - ->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string') + ->and(ruleStrings($createRules['provider_transaction_id']))->toContain('required', 'string', 'unique:fuel_provider_transactions,provider_transaction_id') ->and(ruleStrings($patchRules['provider']))->not->toContain('required') ->and(ruleStrings($patchRules['provider_transaction_id']))->not->toContain('required') ->and($createRules['station_latitude'])->toBe(['nullable', 'numeric']) @@ -257,6 +274,19 @@ protected function canUpdateDriver(): bool ->and($createRules['normalized_payload'])->toBe(['nullable', 'array']) ->and($createRules['raw_payload'])->toBe(['nullable', 'array']) ->and($createRules['meta'])->toBe(['nullable', 'array']); + + // Mirrors fuel_provider_txn_company_provider_unique. The provider is part + // of the scope, and company_uuid is what stops one tenant's provider + // transaction id from colliding with another's — the old index was global. + $rulesWithProvider = CreateFuelTransactionRequest::create('/fleetops-test', 'POST', ['provider' => 'petroapp'])->rules(); + $uniqueRule = collect($rulesWithProvider['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule); + + expect($uniqueRule)->not->toBeNull() + ->and($uniqueRule->constraints)->toBe([ + ['where', 'company_uuid', 'company-uuid'], + ['where', 'provider', 'petroapp'], + ['whereNull', 'deleted_at'], + ]); }); test('vehicle and fuel report requests expose core validation contracts', function () { @@ -273,7 +303,13 @@ protected function canUpdateDriver(): bool ->and($fuelReportRules['driver'])->toBe(['required']) ->and($fuelReportRules['odometer'])->toBe(['required']) ->and($fuelReportRules['volume'])->toBe(['required']) - ->and($fuelReportUpdateRules['driver'])->toBe(['required']) + // The update request inherits these rules, so `required` made every partial + // update fail — PUT with just {"status": "approved"} answered "The driver + // field is required.", and the update action's $request->only() list does not + // even include `driver`. This assertion previously pinned that behaviour. + ->and($fuelReportUpdateRules['driver'])->toBe(['sometimes']) + ->and($fuelReportUpdateRules['odometer'])->toBe(['sometimes']) + ->and($fuelReportUpdateRules['volume'])->toBe(['sometimes']) ->and($internalDriverRules['location'][1])->toBeInstanceOf(ResolvablePoint::class) ->and($internalDriverRules['vehicle'][1])->toBeInstanceOf(ResolvableVehicle::class) ->and($internalDriverRules['latitude'])->toBe(['nullable', 'required_with:longitude', 'numeric']) @@ -621,7 +657,7 @@ protected function canUpdateDriver(): bool expect($request->authorize())->toBeTrue() ->and($rules['identity'])->toBe('required|string') - ->and($rules['code'])->toBe('required|exists:verification_codes,code') + ->and($rules['code'])->toBe('required|string') ->and($rules['name'])->toBe('required|string') ->and($rules['password'])->toBe('required|string|min:8') ->and(ruleStrings($rules['email']))->toContain('email', 'nullable', 'unique:contacts') @@ -649,13 +685,14 @@ protected function canUpdateDriver(): bool expect($request->authorize())->toBeFalse(); - bindFleetOpsRequestSession(['api_credential' => 'credential-uuid']); + session(['company' => 'company-uuid']); + bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']); $createRules = $request->rules(); $patchRules = CreatePartRequest::create('/fleetops-test', 'PATCH')->rules(); expect($request->authorize())->toBeTrue() - ->and($createRules['sku'])->toBe(['nullable', 'string']) + ->and(ruleStrings($createRules['sku']))->toContain('nullable', 'string', 'unique:parts,sku') ->and(ruleStrings($createRules['name']))->toContain('required', 'string') ->and(ruleStrings($patchRules['name']))->not->toContain('required') ->and($createRules['manufacturer'])->toBe(['nullable', 'string']) @@ -671,11 +708,66 @@ protected function canUpdateDriver(): bool ->and($createRules['specs'])->toBe(['nullable', 'array']) ->and($createRules['meta'])->toBe(['nullable', 'array']); + // The SKU uniqueness rule mirrors parts_company_uuid_active_sku_unique: + // scoped to the session company and ignoring soft-deleted parts, so a SKU + // freed by a deleted part — or held by another company — stays available. + // Without it a duplicate reached the driver and returned a 500. + $skuRule = collect($createRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule); + + expect($skuRule)->not->toBeNull() + ->and($skuRule->constraints)->toBe([ + ['where', 'company_uuid', 'company-uuid'], + ['whereNull', 'deleted_at'], + ]); + + // On update the record re-sends its own SKU, so it must not collide with + // itself. The v1 route parameter is a public_id, not a uuid. + $updateRules = CreatePartRequest::create('/fleetops-test', 'PUT') + ->setRouteResolver(fn () => new class { + public function parameter($key, $default = null) + { + return $key === 'id' ? 'part_abc123' : $default; + } + }) + ->rules(); + + $updateSkuRule = collect($updateRules['sku'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule); + + expect($updateSkuRule->constraints)->toContain(['ignore', 'part_abc123', 'public_id']); + bindFleetOpsRequestSession(['is_sanctum_token' => true]); expect($request->authorize())->toBeTrue(); }); + test('duplicate-key requests name the offending field and ignore themselves on update', function () { + session(['company' => 'company-uuid']); + bindFleetOpsRequestSession(['api_credential' => 'credential-uuid', 'company' => 'company-uuid']); + + // Without these the violation surfaces as the framework's generic "has already + // been taken", which does not say which record collided or on what. + expect(CreatePartRequest::create('/fleetops-test', 'POST')->messages()) + ->toBe(['sku.unique' => 'A part with this SKU already exists.']) + ->and(CreateFuelTransactionRequest::create('/fleetops-test', 'POST')->messages()) + ->toBe(['provider_transaction_id.unique' => 'A fuel transaction with this provider transaction id already exists for this provider.']); + + // `provider` is supplied here, so resolveProvider() short-circuits on the input + // and this exercises only the ignore-self clause. The stored-provider fallback + // needs a database and is covered in FuelProviderTransactionControllerContractsTest. + $updateRules = CreateFuelTransactionRequest::create('/fleetops-test', 'PUT', ['provider' => 'petroapp']) + ->setRouteResolver(fn () => new class { + public function parameter($key, $default = null) + { + return $key === 'id' ? 'fuel_provider_transaction_abc123' : $default; + } + }) + ->rules(); + + $updateRule = collect($updateRules['provider_transaction_id'])->first(fn ($rule) => $rule instanceof Illuminate\Validation\Rule); + + expect($updateRule->constraints)->toContain(['ignore', 'fuel_provider_transaction_abc123', 'public_id']); + }); + test('entity equipment payload and simulation requests expose conditional contracts', function () { bindFleetOpsRequestSession(); diff --git a/server/tests/SmallControllerContractsTest.php b/server/tests/SmallControllerContractsTest.php index d7c511b31..682ba2c18 100644 --- a/server/tests/SmallControllerContractsTest.php +++ b/server/tests/SmallControllerContractsTest.php @@ -227,19 +227,30 @@ protected function jsonResponse(array $payload) class FleetOpsPublicNavigatorControllerProbe extends PublicNavigatorController { - public mixed $settings = ['require_photo' => true]; - public array $lookups = []; + public mixed $settings = ['require_photo' => true]; + public array $lookups = []; + public bool $companyMiss = false; protected function findCompanyByPublicId(string $companyId): ?Company { + $this->lookups[] = $companyId; + + // Mirrors the real lookup returning null for an unknown public id. + if ($this->companyMiss) { + return null; + } + $company = new Company(); $company->setRawAttributes(['uuid' => 'company-uuid', 'public_id' => $companyId], true); - $this->lookups[] = $companyId; - return $company; } + protected function errorResponse(string $message, int $statusCode): JsonResponse + { + return new JsonResponse(['errors' => [$message]], $statusCode); + } + protected function driverOnboardSetting(string $companyUuid): mixed { $this->lookups[] = $companyUuid; @@ -412,6 +423,19 @@ function fleetopsSmallExportSelections(object $export): array ]); }); +test('public navigator controller answers 404 for an organization public id that does not resolve', function () { + $controller = new FleetOpsPublicNavigatorControllerProbe(); + $controller->companyMiss = true; + + $response = $controller->getDriverOnboardSettings('company_does_not_exist'); + + // Regression: the unresolved company used to be dereferenced anyway, which threw a + // TypeError out of the controller and rendered an HTML stack trace with a 500. + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['errors' => ['Organization not found.']]) + ->and($controller->lookups)->toBe(['company_does_not_exist']); +}); + test('public contact controller normalizes create input and preserves update fields', function () { $controller = new FleetOpsContactControllerProbe(); diff --git a/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php b/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php new file mode 100644 index 000000000..71d977921 --- /dev/null +++ b/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php @@ -0,0 +1,69 @@ +wheres[] = $arguments; + + return $this; + } +} + +function fleetopsMaintenanceScheduleFilter(FleetOpsMaintenanceScheduleFilterQuery $builder): MaintenanceScheduleFilter +{ + $filter = (new ReflectionClass(MaintenanceScheduleFilter::class))->newInstanceWithoutConstructor(); + + foreach ([ + 'builder' => $builder, + 'session' => new class { + public function get(string $key): ?string + { + return $key === 'company' ? 'company-uuid' : null; + } + }, + ] as $property => $value) { + $reflection = new ReflectionProperty(Filter::class, $property); + $reflection->setAccessible(true); + $reflection->setValue($filter, $value); + } + + return $filter; +} + +test('maintenance schedule filter scopes companies and resolves subject aliases', function () { + $builder = new FleetOpsMaintenanceScheduleFilterQuery(); + $filter = fleetopsMaintenanceScheduleFilter($builder); + + $filter->queryForInternal(); + $filter->queryForPublic(); + $filter->subjectType('vehicle'); + $filter->subjectType('fleet-ops:vehicle'); + $filter->subjectType(Vehicle::class); + $filter->subjectUuid('vehicle-uuid'); + + expect($builder->wheres)->toBe([ + ['company_uuid', 'company-uuid'], + ['company_uuid', 'company-uuid'], + ['subject_type', Vehicle::class], + ['subject_type', Vehicle::class], + ['subject_type', Vehicle::class], + ['subject_uuid', 'vehicle-uuid'], + ]); +}); + +test('maintenance schedule filter ignores empty subject filters', function () { + $builder = new FleetOpsMaintenanceScheduleFilterQuery(); + $filter = fleetopsMaintenanceScheduleFilter($builder); + + $filter->subjectType(null); + $filter->subjectUuid(null); + + expect($builder->wheres)->toBe([]); +}); diff --git a/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php b/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php index bce631b8a..305684dfb 100644 --- a/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +++ b/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php @@ -10,6 +10,7 @@ class FormRequest extends \Illuminate\Http\Request namespace { use Fleetbase\FleetOps\Http\Requests\CreateFuelReportRequest; + use Fleetbase\FleetOps\Http\Requests\UpdateFuelReportRequest; class FleetOpsCreateFuelReportSessionStore { @@ -39,6 +40,26 @@ function fleetopsCreateFuelReportRequestWithSession(array $sessionData): CreateF return CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST'); } + test('fuel report create only fields are required on POST and optional on update', function () { + FleetOpsCreateFuelReportRequestState::$session = new FleetOpsCreateFuelReportSessionStore(['api_credential' => 'api-credential-uuid']); + + // UpdateFuelReportRequest extends this class, so a flat `required` made every + // partial update fail — PUT with just {"status": "approved"} answered "The driver + // field is required." `driver` is the plainest case: the update action's + // $request->only() list does not include it, so the field was demanded and then + // discarded. + $create = CreateFuelReportRequest::create('/fleet-ops/fuel-reports', 'POST')->rules(); + $update = UpdateFuelReportRequest::create('/fleet-ops/fuel-reports/report_1', 'PUT')->rules(); + + foreach (['driver', 'odometer', 'volume'] as $field) { + expect($create[$field])->toBe(['required']) + ->and($update[$field])->toBe(['sometimes']); + } + + expect($create['metric_unit'])->toBe(['nullable']) + ->and($create['amount'])->toBe(['nullable']); + }); + test('create fuel report authorization accepts api credentials or sanctum sessions', function () { expect(fleetopsCreateFuelReportRequestWithSession([])->authorize())->toBeFalse() ->and(fleetopsCreateFuelReportRequestWithSession(['api_credential' => 'api-credential-uuid'])->authorize())->toBeTrue() diff --git a/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php b/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php index 2e9ba20c3..671074648 100644 --- a/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +++ b/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php @@ -180,4 +180,11 @@ public function __call($method, $arguments) $connection->table('companies')->insert(['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Beta']); $empty = (new NavigatorController())->getDriverOnboardSettings('company_two'); expect($empty->getData(true)['driverOnboardSettings'])->toBe([]); + + // An unresolved public id is a client error. This drives the real + // errorResponse() seam, which the probe in SmallControllerContractsTest + // replaces — only the status is asserted, because the harness `response()` + // shim envelopes errors differently from the core-api macro it stands in for. + $missing = (new NavigatorController())->getDriverOnboardSettings('company_missing'); + expect($missing->getStatusCode())->toBe(404); }); diff --git a/server/tests/Unit/Models/SensorLastPositionDefaultTest.php b/server/tests/Unit/Models/SensorLastPositionDefaultTest.php new file mode 100644 index 000000000..c988ce604 --- /dev/null +++ b/server/tests/Unit/Models/SensorLastPositionDefaultTest.php @@ -0,0 +1,130 @@ + ucfirst(str_replace(['_', '-'], ' ', Str::snake((string) $value)))); +} + +function fleetopsSensorDefaultBoot(): SQLiteConnection +{ + $pdo = new PDO('sqlite::memory:'); + $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + // The creating hook is registered against whichever dispatcher is present + // when the model boots, so it has to exist before Device is ever touched. + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + + app()->instance('db', new class($connection) { + public function __construct(public SQLiteConnection $c) + { + } + + public function connection($name = null): SQLiteConnection + { + return $this->c; + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('request', Request::create('/v1/sensors')); + + config()->set('activitylog.enabled', false); + config()->set('activitylog.default_auth_driver', 'web'); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + $schema = $connection->getSchemaBuilder(); + $schema->create('sensors', function ($blueprint) { + $blueprint->increments('id'); + foreach (['uuid', 'public_id', 'internal_id', 'company_uuid', 'telematic_uuid', 'device_uuid', 'name', 'slug', 'type', 'status', 'online', 'unit', 'last_value', 'last_reading_at', 'min_threshold', 'max_threshold', 'location', 'last_position', 'meta', 'data', 'options', 'notes', '_key'] as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + + session(['company' => 'company-1']); + + return $connection; +} + +test('creating a sensor without a position defaults it to the null island', function () { + fleetopsSensorDefaultBoot(); + + $sensor = Sensor::create([ + 'company_uuid' => 'company-1', + 'name' => 'Defaulted Sensor', + 'type' => 'temperature', + ]); + + expect($sensor->last_position)->toBeInstanceOf(SpatialPoint::class) + ->and($sensor->last_position->getLat())->toBe(0.0) + ->and($sensor->last_position->getLng())->toBe(0.0); +}); + +test('creating a sensor with a position leaves it untouched', function () { + fleetopsSensorDefaultBoot(); + + $sensor = Sensor::create([ + 'company_uuid' => 'company-1', + 'name' => 'Positioned Sensor', + 'type' => 'temperature', + 'last_position' => new SpatialPoint(1.3, 103.8), + ]); + + expect($sensor->last_position)->toBeInstanceOf(SpatialPoint::class) + ->and($sensor->last_position->getLat())->toBe(1.3) + ->and($sensor->last_position->getLng())->toBe(103.8); +}); diff --git a/tests/unit/components/vehicle/details/schedules-test.js b/tests/unit/components/vehicle/details/schedules-test.js new file mode 100644 index 000000000..fc9c29ca1 --- /dev/null +++ b/tests/unit/components/vehicle/details/schedules-test.js @@ -0,0 +1,59 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupRenderingTest } from 'dummy/tests/helpers'; + +class StoreStub extends Service { + queries = []; + + query(modelName, params) { + this.queries.push([modelName, params]); + + return Promise.resolve([]); + } +} + +class VehicleActionsStub extends Service { + calls = []; + + scheduleMaintenance(...args) { + this.calls.push(args); + args[2].callback(); + return 'opened'; + } +} + +class NotificationsStub extends Service { + serverError() {} +} + +module('Unit | Component | vehicle/details/schedules', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:store', StoreStub); + this.owner.register('service:vehicle-actions', VehicleActionsStub); + this.owner.register('service:notifications', NotificationsStub); + }); + + test('loads by the backend polymorphic type and reloads after modal creation', async function (assert) { + const vehicle = { id: 'vehicle-uuid' }; + this.set('vehicle', vehicle); + + await render(hbs``); + + const store = this.owner.lookup('service:store'); + assert.deepEqual(store.queries[0], ['maintenance-schedule', { subject_uuid: 'vehicle-uuid', subject_type: 'fleet-ops:vehicle', sort: '-created_at' }]); + + await click('.vehicle-details-schedules button'); + + const vehicleActions = this.owner.lookup('service:vehicle-actions'); + const [scheduledVehicle, options, saveOptions] = vehicleActions.calls[0]; + assert.strictEqual(scheduledVehicle, vehicle); + assert.deepEqual(options, {}); + assert.false(saveOptions.refresh); + + assert.strictEqual(store.queries.length, 2, 'the vehicle schedule list reloads after save'); + }); +}); diff --git a/tests/unit/controllers/maintenance/schedules/index/edit-test.js b/tests/unit/controllers/maintenance/schedules/index/edit-test.js new file mode 100644 index 000000000..9746c8ea9 --- /dev/null +++ b/tests/unit/controllers/maintenance/schedules/index/edit-test.js @@ -0,0 +1,54 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; +import MaintenanceSchedulesIndexEditController from '@fleetbase/fleetops-engine/controllers/maintenance/schedules/index/edit'; + +class HostRouterStub extends Service { + refresh() { + return Promise.resolve(); + } + + transitionTo() { + return Promise.resolve(); + } +} + +class IntlStub extends Service { + calls = []; + + t(key, options) { + this.calls.push([key, options]); + return key === 'resource.maintenance-schedule' ? 'Maintenance Schedule' : 'Schedule updated'; + } +} + +class NotificationsStub extends Service { + messages = []; + + success(message) { + this.messages.push(message); + } + + serverError() {} +} + +module('Unit | Controller | maintenance/schedules/index/edit', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('controller:maintenance/schedules/index/edit', MaintenanceSchedulesIndexEditController); + this.owner.register('service:host-router', HostRouterStub); + this.owner.register('service:intl', IntlStub); + this.owner.register('service:notifications', NotificationsStub); + }); + + test('the update success message includes the schedule name required by the translation', function (assert) { + const controller = this.owner.lookup('controller:maintenance/schedules/index/edit'); + const schedule = { name: 'Vehicle Service' }; + + controller.notifyUpdateSuccess(schedule); + + const intl = this.owner.lookup('service:intl'); + assert.deepEqual(intl.calls.at(-1), ['common.resource-updated-success', { resource: 'Maintenance Schedule', resourceName: 'Vehicle Service' }]); + }); +}); diff --git a/tests/unit/services/vehicle-actions-test.js b/tests/unit/services/vehicle-actions-test.js index ace492ff9..5bf05d15c 100644 --- a/tests/unit/services/vehicle-actions-test.js +++ b/tests/unit/services/vehicle-actions-test.js @@ -15,12 +15,27 @@ class ResourceContextPanelStub extends Service { } } +class FetchStub extends Service {} + +class MaintenanceScheduleActionsStub extends Service { + calls = []; + + modal = { + create: (...args) => { + this.calls.push(args); + return 'opened'; + }, + }; +} + module('Unit | Service | vehicle-actions', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { this.owner.register('service:universe/menu-service', MenuServiceStub); this.owner.register('service:resource-context-panel', ResourceContextPanelStub); + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:maintenance-schedule-actions', MaintenanceScheduleActionsStub); }); test('it exists', function (assert) { @@ -42,6 +57,22 @@ module('Unit | Service | vehicle-actions', function (hooks) { assert.verifySteps(['vehicle reloaded']); }); + test('scheduleMaintenance resolves index vehicles and forwards modal save options', async function (assert) { + const fullVehicle = { id: 'vehicle-uuid', name: 'Truck 1' }; + const indexVehicle = { + loadResource: async () => fullVehicle, + }; + const options = { closeOnSuccess: true }; + const saveOptions = { refresh: false, callback() {} }; + const service = this.owner.lookup('service:vehicle-actions'); + + const result = await service.scheduleMaintenance(indexVehicle, options, saveOptions); + const maintenanceScheduleActions = this.owner.lookup('service:maintenance-schedule-actions'); + + assert.strictEqual(result, 'opened'); + assert.deepEqual(maintenanceScheduleActions.calls, [[{ subject: fullVehicle }, options, saveOptions]]); + }); + test('unassignOrders loads assigned orders, highlights the current job, and posts selected orders', async function (assert) { const service = this.owner.lookup('service:vehicle-actions'); const options = {};