@fleetbase/fleetops-engine 0.6.69 → 0.6.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/RELEASE.md +21 -14
  2. package/addon/components/admin/telematics-settings.hbs +143 -0
  3. package/addon/components/admin/telematics-settings.js +145 -0
  4. package/addon/components/fuel-integration/hub.hbs +1 -1
  5. package/addon/components/layout/fleet-ops-sidebar.js +37 -17
  6. package/addon/components/map/leaflet-live-map.hbs +4 -242
  7. package/addon/components/map/leaflet-live-map.js +2 -1
  8. package/addon/components/map/marker-card/driver.hbs +57 -0
  9. package/addon/components/map/marker-card/driver.js +3 -0
  10. package/addon/components/map/marker-card/vehicle.hbs +78 -0
  11. package/addon/components/map/marker-card/vehicle.js +3 -0
  12. package/addon/components/order/details/activity.hbs +8 -6
  13. package/addon/components/order/details/activity.js +9 -0
  14. package/addon/components/order/details.hbs +29 -0
  15. package/addon/components/order/details.js +26 -0
  16. package/addon/components/order/form/details.hbs +30 -4
  17. package/addon/components/order/form/details.js +46 -0
  18. package/addon/components/order/form/route.hbs +4 -2
  19. package/addon/components/order/form/route.js +11 -0
  20. package/addon/components/order/form.hbs +12 -0
  21. package/addon/components/order/form.js +21 -1
  22. package/addon/components/order/kanban.js +72 -0
  23. package/addon/components/order-config-manager/activity-flow.js +45 -10
  24. package/addon/components/order-config-manager/lifecycle.hbs +250 -0
  25. package/addon/components/order-config-manager/lifecycle.js +358 -0
  26. package/addon/components/order-config-manager.js +5 -1
  27. package/addon/components/telematic/hub.hbs +1 -1
  28. package/addon/components/widget/live-fleet.hbs +8 -40
  29. package/addon/controllers/analytics/reports/index.js +15 -1
  30. package/addon/controllers/connectivity/devices/index/details.js +1 -0
  31. package/addon/controllers/connectivity/devices/index.js +12 -2
  32. package/addon/controllers/connectivity/events/index.js +31 -1
  33. package/addon/controllers/connectivity/fuel-providers/index.js +6 -4
  34. package/addon/controllers/connectivity/sensors/index/details.js +1 -1
  35. package/addon/controllers/connectivity/sensors/index.js +41 -1
  36. package/addon/controllers/connectivity/telematics/index.js +4 -0
  37. package/addon/controllers/maintenance/equipment/index/details.js +2 -2
  38. package/addon/controllers/maintenance/equipment/index.js +36 -10
  39. package/addon/controllers/maintenance/inspection-forms/index.js +32 -12
  40. package/addon/controllers/maintenance/inspection-submissions/index/details.js +2 -1
  41. package/addon/controllers/maintenance/inspection-submissions/index.js +56 -10
  42. package/addon/controllers/maintenance/maintenances/index/details.js +2 -0
  43. package/addon/controllers/maintenance/maintenances/index.js +24 -1
  44. package/addon/controllers/maintenance/parts/index/details.js +2 -2
  45. package/addon/controllers/maintenance/parts/index.js +36 -9
  46. package/addon/controllers/maintenance/schedules/index.js +48 -6
  47. package/addon/controllers/maintenance/work-orders/index/details.js +3 -3
  48. package/addon/controllers/maintenance/work-orders/index.js +47 -9
  49. package/addon/controllers/management/contacts/customers.js +13 -0
  50. package/addon/controllers/management/contacts/index.js +47 -1
  51. package/addon/controllers/management/drivers/index/details.js +1 -0
  52. package/addon/controllers/management/drivers/index.js +36 -2
  53. package/addon/controllers/management/fleets/index/details.js +1 -0
  54. package/addon/controllers/management/fleets/index.js +43 -1
  55. package/addon/controllers/management/fuel-reports/index.js +26 -2
  56. package/addon/controllers/management/fuel-transactions/index.js +45 -7
  57. package/addon/controllers/management/issues/index/details.js +1 -0
  58. package/addon/controllers/management/issues/index.js +26 -2
  59. package/addon/controllers/management/places/index.js +40 -1
  60. package/addon/controllers/management/trailers/index/details.js +1 -0
  61. package/addon/controllers/management/trailers/index.js +50 -2
  62. package/addon/controllers/management/vehicles/index/details.js +1 -0
  63. package/addon/controllers/management/vehicles/index.js +37 -2
  64. package/addon/controllers/management/vendors/index.js +30 -2
  65. package/addon/controllers/operations/orders/index/details.js +61 -5
  66. package/addon/controllers/operations/orders/index.js +66 -4
  67. package/addon/controllers/operations/service-rates/index.js +18 -1
  68. package/addon/controllers/settings/index.js +13 -0
  69. package/addon/controllers/settings/telematics.js +113 -0
  70. package/addon/extension.js +70 -6
  71. package/addon/routes/analytics/index.js +14 -1
  72. package/addon/routes/analytics/reports/index/details.js +1 -1
  73. package/addon/routes/analytics/reports/index/edit.js +1 -1
  74. package/addon/routes/analytics/reports/index/new.js +14 -1
  75. package/addon/routes/analytics/reports.js +14 -1
  76. package/addon/routes/connectivity/devices/index/new.js +14 -1
  77. package/addon/routes/connectivity/devices.js +14 -1
  78. package/addon/routes/connectivity/events.js +14 -1
  79. package/addon/routes/connectivity/fuel-providers/details/transactions.js +12 -0
  80. package/addon/routes/connectivity/fuel-providers/details.js +9 -0
  81. package/addon/routes/connectivity/fuel-providers/edit.js +9 -0
  82. package/addon/routes/connectivity/fuel-providers/new.js +14 -1
  83. package/addon/routes/connectivity/fuel-providers.js +14 -1
  84. package/addon/routes/connectivity/sensors/index/new.js +14 -1
  85. package/addon/routes/connectivity/sensors.js +14 -1
  86. package/addon/routes/connectivity/telematics/new.js +14 -1
  87. package/addon/routes/connectivity/telematics.js +14 -1
  88. package/addon/routes/connectivity/tracking.js +14 -1
  89. package/addon/routes/maintenance/equipment/index/details.js +2 -2
  90. package/addon/routes/maintenance/equipment/index/edit.js +2 -2
  91. package/addon/routes/maintenance/equipment/index/new.js +14 -1
  92. package/addon/routes/maintenance/equipment.js +14 -1
  93. package/addon/routes/maintenance/index.js +14 -1
  94. package/addon/routes/maintenance/inspection-forms/index/details.js +10 -1
  95. package/addon/routes/maintenance/inspection-forms/index/edit.js +10 -1
  96. package/addon/routes/maintenance/inspection-forms/index/new.js +14 -1
  97. package/addon/routes/maintenance/inspection-forms.js +14 -1
  98. package/addon/routes/maintenance/inspection-submissions/index/details.js +10 -1
  99. package/addon/routes/maintenance/inspection-submissions/index/edit.js +10 -1
  100. package/addon/routes/maintenance/inspection-submissions/index/new.js +14 -1
  101. package/addon/routes/maintenance/inspection-submissions.js +14 -1
  102. package/addon/routes/maintenance/maintenances/index/details.js +2 -2
  103. package/addon/routes/maintenance/maintenances/index/edit.js +2 -2
  104. package/addon/routes/maintenance/maintenances/index/new.js +14 -1
  105. package/addon/routes/maintenance/maintenances.js +14 -1
  106. package/addon/routes/maintenance/parts/index/details.js +2 -2
  107. package/addon/routes/maintenance/parts/index/edit.js +2 -2
  108. package/addon/routes/maintenance/parts/index/new.js +14 -1
  109. package/addon/routes/maintenance/parts.js +14 -1
  110. package/addon/routes/maintenance/schedules/index/details.js +2 -2
  111. package/addon/routes/maintenance/schedules/index/edit.js +2 -2
  112. package/addon/routes/maintenance/schedules/index/new.js +15 -1
  113. package/addon/routes/maintenance/schedules.js +15 -1
  114. package/addon/routes/maintenance/work-orders/index/details.js +2 -2
  115. package/addon/routes/maintenance/work-orders/index/edit.js +2 -2
  116. package/addon/routes/maintenance/work-orders/index/new.js +14 -1
  117. package/addon/routes/maintenance/work-orders.js +14 -1
  118. package/addon/routes/management/fuel-transactions/index/details.js +9 -0
  119. package/addon/routes/management/fuel-transactions.js +14 -1
  120. package/addon/routes/management/index.js +13 -0
  121. package/addon/routes/management/trailers.js +16 -0
  122. package/addon/routes/management/vendors/integrated/details.js +14 -1
  123. package/addon/routes/management/vendors/integrated/edit.js +14 -1
  124. package/addon/routes/management/vendors/integrated/new.js +14 -1
  125. package/addon/routes/management/vendors/integrated.js +14 -1
  126. package/addon/routes/operations/orders/index/details.js +1 -0
  127. package/addon/routes/operations/orders.js +40 -1
  128. package/addon/routes/operations/routes/index/details.js +14 -1
  129. package/addon/routes/operations/routes/index/new.js +13 -0
  130. package/addon/routes/operations/routes.js +14 -1
  131. package/addon/routes/operations/service-rates/index/new.js +11 -0
  132. package/addon/routes/operations/service-rates.js +14 -1
  133. package/addon/routes/settings/avatars.js +14 -1
  134. package/addon/routes/settings/custom-fields.js +14 -1
  135. package/addon/routes/settings/map.js +14 -1
  136. package/addon/routes/settings/notifications.js +11 -0
  137. package/addon/routes/settings/orchestrator.js +1 -1
  138. package/addon/routes/settings/payments/index.js +1 -1
  139. package/addon/routes/settings/routing.js +14 -1
  140. package/addon/routes/settings/scheduling.js +14 -1
  141. package/addon/routes/settings/telematics.js +21 -0
  142. package/addon/routes/virtual.js +11 -0
  143. package/addon/routes.js +1 -0
  144. package/addon/services/contact-actions.js +1 -0
  145. package/addon/services/customer-actions.js +2 -1
  146. package/addon/services/device-actions.js +1 -0
  147. package/addon/services/device-event-actions.js +1 -0
  148. package/addon/services/driver-actions.js +4 -13
  149. package/addon/services/equipment-actions.js +1 -0
  150. package/addon/services/fleet-actions.js +1 -0
  151. package/addon/services/fuel-report-actions.js +1 -0
  152. package/addon/services/inspection-form-actions.js +1 -0
  153. package/addon/services/inspection-submission-actions.js +1 -0
  154. package/addon/services/integrated-vendor-actions.js +1 -0
  155. package/addon/services/issue-actions.js +1 -0
  156. package/addon/services/maintenance-actions.js +1 -0
  157. package/addon/services/maintenance-schedule-actions.js +2 -1
  158. package/addon/services/order-actions.js +7 -0
  159. package/addon/services/order-presentation.js +129 -0
  160. package/addon/services/part-actions.js +1 -0
  161. package/addon/services/place-actions.js +1 -0
  162. package/addon/services/sensor-actions.js +1 -0
  163. package/addon/services/service-area-actions.js +1 -0
  164. package/addon/services/service-rate-actions.js +1 -0
  165. package/addon/services/telematic-actions.js +1 -0
  166. package/addon/services/trailer-actions.js +4 -14
  167. package/addon/services/vehicle-actions.js +4 -13
  168. package/addon/services/vendor-actions.js +1 -0
  169. package/addon/services/work-order-actions.js +1 -0
  170. package/addon/services/zone-actions.js +1 -0
  171. package/addon/templates/analytics/reports/index/details.hbs +1 -0
  172. package/addon/templates/analytics/reports/index.hbs +1 -0
  173. package/addon/templates/connectivity/devices/index/details.hbs +1 -0
  174. package/addon/templates/connectivity/devices/index.hbs +1 -0
  175. package/addon/templates/connectivity/events/index.hbs +1 -0
  176. package/addon/templates/connectivity/sensors/index/details.hbs +1 -0
  177. package/addon/templates/connectivity/sensors/index.hbs +1 -0
  178. package/addon/templates/maintenance/equipment/index/details.hbs +1 -0
  179. package/addon/templates/maintenance/equipment/index.hbs +2 -0
  180. package/addon/templates/maintenance/inspection-forms/index/details.hbs +1 -0
  181. package/addon/templates/maintenance/inspection-forms/index.hbs +1 -0
  182. package/addon/templates/maintenance/inspection-submissions/index/details.hbs +1 -0
  183. package/addon/templates/maintenance/inspection-submissions/index.hbs +1 -0
  184. package/addon/templates/maintenance/maintenances/index/details.hbs +1 -0
  185. package/addon/templates/maintenance/maintenances/index.hbs +1 -0
  186. package/addon/templates/maintenance/parts/index/details.hbs +1 -0
  187. package/addon/templates/maintenance/parts/index.hbs +2 -0
  188. package/addon/templates/maintenance/schedules/index/details.hbs +1 -0
  189. package/addon/templates/maintenance/schedules/index.hbs +2 -0
  190. package/addon/templates/maintenance/work-orders/index/details.hbs +1 -0
  191. package/addon/templates/maintenance/work-orders/index.hbs +1 -0
  192. package/addon/templates/management/contacts/customers/details.hbs +1 -0
  193. package/addon/templates/management/contacts/customers.hbs +1 -0
  194. package/addon/templates/management/contacts/index/details.hbs +1 -0
  195. package/addon/templates/management/contacts/index.hbs +1 -0
  196. package/addon/templates/management/contacts.hbs +1 -0
  197. package/addon/templates/management/drivers/index/details.hbs +1 -0
  198. package/addon/templates/management/drivers/index.hbs +2 -0
  199. package/addon/templates/management/fleets/index/details.hbs +1 -0
  200. package/addon/templates/management/fleets/index.hbs +1 -0
  201. package/addon/templates/management/fuel-reports/index/details.hbs +1 -0
  202. package/addon/templates/management/fuel-reports/index.hbs +1 -0
  203. package/addon/templates/management/fuel-transactions/index.hbs +1 -0
  204. package/addon/templates/management/issues/index/details.hbs +1 -0
  205. package/addon/templates/management/issues/index.hbs +1 -0
  206. package/addon/templates/management/places/index/details.hbs +1 -0
  207. package/addon/templates/management/places/index.hbs +1 -0
  208. package/addon/templates/management/trailers/index/details.hbs +1 -0
  209. package/addon/templates/management/trailers/index.hbs +2 -0
  210. package/addon/templates/management/vehicles/index/details.hbs +1 -0
  211. package/addon/templates/management/vehicles/index.hbs +2 -0
  212. package/addon/templates/management/vendors/index/details.hbs +1 -0
  213. package/addon/templates/management/vendors/index.hbs +2 -0
  214. package/addon/templates/management/vendors/integrated.hbs +1 -0
  215. package/addon/templates/operations/orders/index/details.hbs +1 -0
  216. package/addon/templates/operations/orders/index.hbs +1 -0
  217. package/addon/templates/operations/service-rates/index/details.hbs +1 -0
  218. package/addon/templates/operations/service-rates/index.hbs +1 -0
  219. package/addon/templates/settings/telematics.hbs +70 -0
  220. package/addon/utils/normalize-order-config-flow.js +6 -1
  221. package/app/components/admin/telematics-settings.js +1 -0
  222. package/app/components/map/marker-card/driver.js +1 -0
  223. package/app/components/map/marker-card/vehicle.js +1 -0
  224. package/app/controllers/settings/telematics.js +1 -0
  225. package/app/routes/settings/telematics.js +1 -0
  226. package/app/services/order-presentation.js +1 -0
  227. package/app/templates/settings/telematics.js +1 -0
  228. package/composer.json +2 -2
  229. package/docs/AFAQY.md +1 -1
  230. package/docs/TELEMATICS_QUEUES.md +1 -1
  231. package/docs/TELEMETRY_ARCHITECTURE.md +72 -1
  232. package/extension.json +1 -1
  233. package/package.json +3 -3
  234. package/server/config/telemetry.php +12 -0
  235. package/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php +40 -0
  236. package/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php +40 -0
  237. package/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php +40 -0
  238. package/server/src/Auth/Schemas/FleetOps.php +104 -5
  239. package/server/src/Console/Commands/DrainTelematicInbox.php +3 -13
  240. package/server/src/Console/Commands/ProcessOperationalAlerts.php +3 -3
  241. package/server/src/Console/Commands/PruneTelematicsData.php +318 -0
  242. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +9 -0
  243. package/server/src/Http/Controllers/FleetOpsController.php +21 -0
  244. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +11 -0
  245. package/server/src/Http/Controllers/Internal/v1/ContactController.php +11 -0
  246. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +14 -0
  247. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +13 -0
  248. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +11 -0
  249. package/server/src/Http/Controllers/Internal/v1/DriverController.php +22 -0
  250. package/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php +13 -0
  251. package/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php +15 -0
  252. package/server/src/Http/Controllers/Internal/v1/FuelReportController.php +9 -3
  253. package/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +15 -0
  254. package/server/src/Http/Controllers/Internal/v1/LiveController.php +16 -0
  255. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +15 -0
  256. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +15 -0
  257. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +15 -0
  258. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +11 -0
  259. package/server/src/Http/Controllers/Internal/v1/NavigatorController.php +42 -1
  260. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +12 -0
  261. package/server/src/Http/Controllers/Internal/v1/OrderController.php +56 -4
  262. package/server/src/Http/Controllers/Internal/v1/PaymentController.php +13 -0
  263. package/server/src/Http/Controllers/Internal/v1/RadarController.php +19 -0
  264. package/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +9 -3
  265. package/server/src/Http/Controllers/Internal/v1/SettingController.php +465 -14
  266. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +21 -0
  267. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +21 -0
  268. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +21 -0
  269. package/server/src/Http/Controllers/Internal/v1/VendorController.php +17 -0
  270. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +11 -0
  271. package/server/src/Jobs/DispatchTelematicsRetentionJobs.php +70 -0
  272. package/server/src/Jobs/PruneTelematicsDataJob.php +74 -0
  273. package/server/src/Listeners/NotifyDriverOnShiftChange.php +3 -3
  274. package/server/src/Mail/CustomerCredentialsMail.php +1 -1
  275. package/server/src/Models/Order.php +35 -3
  276. package/server/src/Models/OrderConfig.php +129 -0
  277. package/server/src/Models/TrackingNumber.php +8 -4
  278. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +9 -1
  279. package/server/src/Providers/FleetOpsServiceProvider.php +3 -0
  280. package/server/src/Support/Analytics/LiveFleet.php +18 -2
  281. package/server/src/Support/Authorization.php +103 -0
  282. package/server/src/Support/Database/TableIndexes.php +66 -0
  283. package/server/src/Support/OrderTracker.php +2 -2
  284. package/server/src/Support/Reporting/FleetOpsReportSchema.php +476 -116
  285. package/server/src/Support/Telematics/Retention/RetentionPolicy.php +300 -0
  286. package/server/src/Support/Telematics/Retention/TelemetryActivity.php +40 -0
  287. package/server/src/Support/Telematics/TelematicService.php +12 -2
  288. package/server/src/Support/Telematics/Telemetry/Ingestor.php +4 -2
  289. package/server/src/Tracking/TrackingIntelligenceService.php +1 -1
  290. package/server/src/Tracking/TrackingOptions.php +2 -2
  291. package/server/src/Traits/AuthorizesMethods.php +37 -0
  292. package/server/src/routes.php +9 -1
  293. package/server/tests/AnalyticsRoutesTest.php +29 -2
  294. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +16 -1
  295. package/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php +115 -0
  296. package/server/tests/Feature/Http/Internal/OrderControllerActivityFlowsTest.php +72 -0
  297. package/server/tests/Feature/Http/Internal/OrderControllerCreateRecordTest.php +26 -0
  298. package/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +71 -0
  299. package/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php +119 -0
  300. package/server/tests/InternalImportExportControllerContractsTest.php +5 -8
  301. package/server/tests/NavigatorControllerContractsTest.php +36 -4
  302. package/server/tests/NotificationAndMailContractsTest.php +31 -0
  303. package/server/tests/ObserverContractsTest.php +1 -1
  304. package/server/tests/OrchestratorConsumableApiTest.php +42 -0
  305. package/server/tests/OrderConfigLifecycleTest.php +120 -0
  306. package/server/tests/ProviderContractsTest.php +10 -0
  307. package/server/tests/ReportSchemaContractsTest.php +120 -6
  308. package/server/tests/SettingControllerContractsTest.php +429 -18
  309. package/server/tests/SmallControllerContractsTest.php +8 -5
  310. package/server/tests/Support/PruneTelematicsDataProbe.php +55 -0
  311. package/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php +2 -2
  312. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +353 -0
  313. package/server/tests/Unit/Events/OrderDispatchFailedTest.php +2 -1
  314. package/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php +114 -0
  315. package/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php +60 -0
  316. package/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +20 -0
  317. package/server/tests/Unit/Support/Analytics/LiveFleetTest.php +6 -0
  318. package/server/tests/Unit/Support/AuthorizationTest.php +142 -0
  319. package/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php +12 -1
  320. package/server/tests/Unit/Support/MoreQuerySeamsTest.php +11 -2
  321. package/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php +190 -0
  322. package/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php +68 -0
  323. package/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +109 -0
  324. package/translations/en-us.yaml +108 -0
@@ -0,0 +1,115 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Support\Database\TableIndexes;
4
+ use Illuminate\Database\SQLiteConnection;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ /**
8
+ * The retention sweep ranges over (company_uuid, created_at) and
9
+ * (telematic_uuid, updated_at); these migrations add those indexes once,
10
+ * skip equivalent existing ones, and roll back only what they created.
11
+ */
12
+ function fleetopsRetentionIndexDatabase(): SQLiteConnection
13
+ {
14
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:', '', ['driver' => 'sqlite']);
15
+ Schema::swap($connection->getSchemaBuilder());
16
+
17
+ return $connection;
18
+ }
19
+
20
+ function fleetopsRetentionIndexMigrations(): array
21
+ {
22
+ $migrations = [];
23
+ foreach (glob(dirname(__DIR__, 4) . '/migrations/2026_09_23_00000*_add_retention_index_to_*.php') as $path) {
24
+ $migrations[basename($path)] = fn () => require $path;
25
+ }
26
+
27
+ return $migrations;
28
+ }
29
+
30
+ test('retention index migrations add composite indexes once and roll them back', function () {
31
+ fleetopsRetentionIndexDatabase();
32
+ Schema::create('device_events', function ($table) {
33
+ $table->increments('id');
34
+ $table->uuid('company_uuid')->nullable();
35
+ $table->timestamps();
36
+ });
37
+ Schema::create('positions', function ($table) {
38
+ $table->increments('id');
39
+ $table->uuid('company_uuid')->nullable();
40
+ $table->timestamps();
41
+ });
42
+ Schema::create('telematic_sync_runs', function ($table) {
43
+ $table->uuid('uuid')->primary();
44
+ $table->uuid('telematic_uuid')->index();
45
+ $table->timestamps();
46
+ });
47
+
48
+ $expected = [
49
+ 'device_events' => ['device_events_company_created_at_index', ['company_uuid', 'created_at']],
50
+ 'positions' => ['positions_company_created_at_index', ['company_uuid', 'created_at']],
51
+ 'telematic_sync_runs' => ['telematic_sync_runs_telematic_updated_at_index', ['telematic_uuid', 'updated_at']],
52
+ ];
53
+ $migrations = fleetopsRetentionIndexMigrations();
54
+ expect($migrations)->toHaveCount(3);
55
+ foreach ($migrations as $load) {
56
+ $load()->up();
57
+ $load()->up();
58
+ }
59
+ foreach ($expected as $table => [$name, $columns]) {
60
+ expect(TableIndexes::for($table)[$name] ?? null)->toBe($columns)
61
+ ->and(TableIndexes::covers($table, $columns))->toBeTrue()
62
+ ->and(TableIndexes::covers($table, ['created_at']))->toBeFalse();
63
+ }
64
+ // The existing single-column index on telematic_uuid does not cover the composite.
65
+ expect(count(array_filter(TableIndexes::for('telematic_sync_runs'), fn ($columns) => $columns === ['telematic_uuid'])))->toBe(1);
66
+
67
+ foreach ($migrations as $load) {
68
+ $load()->down();
69
+ }
70
+ foreach ($expected as $table => [$name]) {
71
+ expect(TableIndexes::for($table))->not->toHaveKey($name);
72
+ }
73
+ });
74
+
75
+ test('retention index migrations skip missing tables, missing columns and equivalent indexes', function () {
76
+ fleetopsRetentionIndexDatabase();
77
+ $migrations = fleetopsRetentionIndexMigrations();
78
+
79
+ // No tables at all: nothing to do either way.
80
+ foreach ($migrations as $load) {
81
+ $load()->up();
82
+ $load()->down();
83
+ }
84
+
85
+ Schema::create('device_events', fn ($table) => $table->increments('id'));
86
+ Schema::create('positions', function ($table) {
87
+ $table->increments('id');
88
+ $table->uuid('company_uuid')->nullable();
89
+ $table->timestamps();
90
+ $table->index(['company_uuid', 'created_at', 'id'], 'positions_existing_company_created');
91
+ });
92
+ Schema::create('telematic_sync_runs', function ($table) {
93
+ $table->uuid('uuid')->primary();
94
+ $table->uuid('telematic_uuid');
95
+ $table->timestamps();
96
+ $table->index(['telematic_uuid', 'updated_at'], 'telematic_sync_runs_telematic_updated_at_index');
97
+ });
98
+ Schema::table('telematic_sync_runs', fn ($table) => $table->index('updated_at', 'unrelated_updated_at'));
99
+
100
+ foreach ($migrations as $load) {
101
+ $load()->up();
102
+ }
103
+ expect(TableIndexes::for('device_events'))->toBe([])
104
+ ->and(array_keys(TableIndexes::for('positions')))->toBe(['positions_existing_company_created'])
105
+ ->and(TableIndexes::for('telematic_sync_runs'))->toHaveKeys(['telematic_sync_runs_telematic_updated_at_index', 'unrelated_updated_at']);
106
+
107
+ // Rolling back never drops an index the migration did not create, even under the reserved name when its shape differs.
108
+ Schema::table('telematic_sync_runs', fn ($table) => $table->dropIndex('telematic_sync_runs_telematic_updated_at_index'));
109
+ Schema::table('telematic_sync_runs', fn ($table) => $table->index('updated_at', 'telematic_sync_runs_telematic_updated_at_index'));
110
+ foreach ($migrations as $load) {
111
+ $load()->down();
112
+ }
113
+ expect(array_keys(TableIndexes::for('positions')))->toBe(['positions_existing_company_created'])
114
+ ->and(TableIndexes::for('telematic_sync_runs')['telematic_sync_runs_telematic_updated_at_index'])->toBe(['updated_at']);
115
+ });
@@ -661,3 +661,75 @@ test('set destination and next activity guard missing orders and configs', funct
661
661
  $noConfig = $controller->nextActivity('order_internal', Request::create('/x', 'GET'));
662
662
  expect($noConfig->getData(true)['error'] ?? '')->toBe('No order config found for order.');
663
663
  });
664
+
665
+ function fleetopsInternalActivityUseLifecycle(SQLiteConnection $connection, array $lifecycle = []): void
666
+ {
667
+ $activity = fn (string $code, array $children = []) => ['key' => $code, 'code' => $code, 'status' => ucfirst($code), 'details' => ucfirst($code) . ' loan', 'activities' => $children, 'logic' => [], 'events' => [], 'complete' => $code === 'completed'];
668
+
669
+ $connection->table('order_configs')->where('uuid', 'config-1')->update([
670
+ 'flow' => json_encode([
671
+ 'requested' => $activity('requested', ['assigned', 'cancelled']),
672
+ 'assigned' => $activity('assigned', ['completed']),
673
+ 'completed' => $activity('completed'),
674
+ 'cancelled' => $activity('cancelled'),
675
+ ]),
676
+ 'meta' => json_encode(['lifecycle' => array_replace([
677
+ 'initial' => 'requested',
678
+ 'completed' => 'completed',
679
+ 'canceled' => 'cancelled',
680
+ 'dispatch' => false,
681
+ 'strict_transitions' => true,
682
+ ], $lifecycle)]),
683
+ ]);
684
+ }
685
+
686
+ test('strict transitions reject activities outside the configured flow and use the configured definition', function () {
687
+ $connection = fleetopsInternalActivityBoot();
688
+ $controller = new OrderController();
689
+
690
+ fleetopsInternalActivityUseLifecycle($connection);
691
+ fleetopsInternalActivitySeedOrder($connection, ['status' => 'requested']);
692
+
693
+ // Not a child of the current activity
694
+ $skipped = $controller->updateActivity('order_internal', Request::create('/x', 'POST', ['activity' => ['key' => 'completed', 'code' => 'completed', 'status' => 'Completed'], 'bypass_proof' => 1]));
695
+ expect($skipped->getStatusCode())->toBe(422)
696
+ ->and($skipped->getData(true)['error'])->toContain('not a permitted next step');
697
+
698
+ // Not in the flow at all
699
+ $unknown = $controller->updateActivity('order_internal', Request::create('/x', 'POST', ['activity' => ['key' => 'started', 'code' => 'started', 'status' => 'Started'], 'bypass_proof' => 1]));
700
+ expect($unknown->getStatusCode())->toBe(422);
701
+
702
+ // A configured child is accepted, with the server-side definition replacing the client's
703
+ $assigned = $controller->updateActivity('order_internal', Request::create('/x', 'POST', ['activity' => ['key' => 'assigned', 'code' => 'assigned', 'status' => 'Tampered'], 'bypass_proof' => 1]));
704
+ expect($assigned)->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Order::class)
705
+ ->and($connection->table('tracking_statuses')->where('code', 'ASSIGNED')->value('status'))->toBe('Assigned');
706
+ });
707
+
708
+ test('dispatch order refuses orders whose lifecycle disables dispatch', function () {
709
+ $connection = fleetopsInternalActivityBoot();
710
+ $controller = new OrderController();
711
+
712
+ fleetopsInternalActivityUseLifecycle($connection);
713
+ fleetopsInternalActivitySeedOrder($connection, ['status' => 'requested', 'driver_assigned_uuid' => 'driver-1']);
714
+
715
+ $response = $controller->dispatchOrder(Request::create('/x', 'POST', ['order' => 'order_internal']));
716
+ expect($response->getData(true)['error'] ?? '')->toContain('not dispatched')
717
+ ->and($connection->table('orders')->value('dispatched'))->toBe(0);
718
+ });
719
+
720
+ test('orders with a configured lifecycle start their tracking at the initial activity', function () {
721
+ $connection = fleetopsInternalActivityBoot();
722
+
723
+ // No config, or a config without a lifecycle, keeps the default created status
724
+ expect((new Fleetbase\FleetOps\Models\Order())->getInitialTrackingStatus())->toBeNull();
725
+ $order = new Fleetbase\FleetOps\Models\Order();
726
+ $order->order_config_uuid = 'config-1';
727
+ expect($order->getInitialTrackingStatus())->toBeNull();
728
+
729
+ fleetopsInternalActivityUseLifecycle($connection);
730
+ expect($order->getInitialTrackingStatus())->toBe(['code' => 'requested', 'status' => 'Requested', 'details' => 'Requested loan']);
731
+
732
+ // An initial activity missing from the flow falls back to the default
733
+ fleetopsInternalActivityUseLifecycle($connection, ['initial' => 'nowhere']);
734
+ expect($order->getInitialTrackingStatus())->toBeNull();
735
+ });
@@ -486,3 +486,29 @@ test('create record dispatches orders when the dispatch flag is not disabled', f
486
486
  expect($result)->toBeArray()->toHaveKey('order')
487
487
  ->and($connection->table('orders')->value('dispatched'))->not->toBeNull();
488
488
  });
489
+
490
+ test('create record starts configured lifecycles at their initial activity and never dispatches them', function () {
491
+ $connection = fleetopsInternalOrderCreateBoot();
492
+ $connection->table('order_configs')->where('uuid', 'config-1')->update([
493
+ 'flow' => json_encode([
494
+ 'requested' => ['key' => 'requested', 'code' => 'requested', 'status' => 'Requested', 'details' => 'Loan requested', 'activities' => ['completed']],
495
+ 'completed' => ['key' => 'completed', 'code' => 'completed', 'status' => 'Completed', 'details' => 'Loan completed', 'activities' => []],
496
+ ]),
497
+ 'meta' => json_encode(['lifecycle' => ['initial' => 'requested', 'completed' => 'completed', 'dispatch' => false]]),
498
+ ]);
499
+
500
+ // Asks to be dispatched; the lifecycle overrides both the status and the dispatch flag
501
+ $result = (new OrderController())->createRecord(Request::create('/int/v1/orders', 'POST', [
502
+ 'order' => [
503
+ 'status' => 'created',
504
+ 'dispatched' => true,
505
+ 'adhoc' => true,
506
+ 'payload' => ['type' => 'transport'],
507
+ ],
508
+ ]));
509
+
510
+ expect($result)->toBeArray()->toHaveKey('order')
511
+ ->and($connection->table('orders')->value('status'))->toBe('requested')
512
+ ->and((bool) $connection->table('orders')->value('dispatched'))->toBeFalse()
513
+ ->and((bool) $connection->table('orders')->value('adhoc'))->toBeFalse();
514
+ });
@@ -131,3 +131,74 @@ test('setting helpers read notification and tracking registries', function () {
131
131
  ->and($helper('notificationsByPackage', 'fleet-ops'))->toBeArray()
132
132
  ->and($helper('trackingProviders'))->toBeArray();
133
133
  });
134
+
135
+ test('history policy reconciliation clamps every organization and preserves inherited and internal settings', function () {
136
+ $connection = fleetopsSettingHelpersBoot();
137
+ $key = Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy::SETTING_KEY;
138
+ $controller = new class extends SettingController {
139
+ public ?Closure $beforeLock = null;
140
+
141
+ public function reconcile(array $defaults): void
142
+ {
143
+ $this->reconcileTelematicsCompanyPreferences($defaults);
144
+ }
145
+
146
+ public function lock(Closure $callback): mixed
147
+ {
148
+ return parent::withTelematicsPolicyLock($callback);
149
+ }
150
+
151
+ protected function withTelematicsPolicyLock(Closure $callback): mixed
152
+ {
153
+ if ($this->beforeLock) {
154
+ ($this->beforeLock)();
155
+ $this->beforeLock = null;
156
+ }
157
+
158
+ return parent::withTelematicsPolicyLock($callback);
159
+ }
160
+
161
+ protected function telematicsDefaults(): array
162
+ {
163
+ return ['max_event_retention_days' => 90, 'max_position_retention_days' => 360];
164
+ }
165
+ };
166
+ // The first write creates the lock anchor; subsequent writes reuse it.
167
+ expect($controller->lock(fn () => 'saved'))->toBe('saved');
168
+ expect($controller->lock(fn () => $connection->transactionLevel()))->toBe(1)
169
+ ->and($connection->table('settings')->where('key', $key)->count())->toBe(1);
170
+ foreach ([
171
+ 'a' => ['event_retention_days' => 0, 'position_retention_days' => 900, 'log_telemetry_activity' => true],
172
+ 'b' => ['event_retention_days' => 30],
173
+ 'c' => [],
174
+ ] as $company => $preferences) {
175
+ $connection->table('settings')->insert(['key' => "company.$company.$key", 'value' => json_encode($preferences)]);
176
+ }
177
+ $controller->reconcile(['max_event_retention_days' => 0, 'max_position_retention_days' => 0]);
178
+ $read = fn ($company) => json_decode($connection->table('settings')->where('key', "company.$company.$key")->value('value'), true);
179
+ expect($read('a')['event_retention_days'])->toBe(0);
180
+ $controller->reconcile(['max_event_retention_days' => 90, 'max_position_retention_days' => 360]);
181
+ expect($read('a'))->toBe(['event_retention_days' => 90, 'position_retention_days' => 360, 'log_telemetry_activity' => true])
182
+ ->and($read('b'))->toBe(['event_retention_days' => 30])->and($read('c'))->toBe([]);
183
+ // A tenant removed after the batch is read must not be recreated.
184
+ $controller->beforeLock = fn () => $connection->table('settings')->where('key', "company.a.$key")->delete();
185
+ $controller->reconcile(['max_event_retention_days' => 90]);
186
+ expect($connection->table('settings')->where('key', "company.a.$key")->exists())->toBeFalse();
187
+ });
188
+
189
+ test('company order configuration keys exclude other tenants and deleted configurations', function () {
190
+ $connection = fleetopsSettingHelpersBoot();
191
+ $connection->getSchemaBuilder()->create('order_configs', function ($table) {
192
+ $table->increments('id');
193
+ $table->string('uuid');
194
+ $table->string('public_id')->nullable();
195
+ $table->string('company_uuid');
196
+ $table->softDeletes();
197
+ });
198
+ $connection->table('order_configs')->insert([
199
+ ['uuid' => 'own', 'public_id' => 'own-public', 'company_uuid' => 'company-1', 'deleted_at' => null],
200
+ ['uuid' => 'other', 'public_id' => 'other-public', 'company_uuid' => 'company-2', 'deleted_at' => null],
201
+ ['uuid' => 'deleted', 'public_id' => null, 'company_uuid' => 'company-1', 'deleted_at' => '2026-01-01'],
202
+ ]);
203
+ expect((new ReflectionMethod(SettingController::class, 'companyOrderConfigKeys'))->invoke(new SettingController()))->toBe(['own', 'own-public']);
204
+ });
@@ -0,0 +1,119 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\SettingController;
4
+ use Illuminate\Database\MySqlConnection;
5
+ use Illuminate\Database\QueryException;
6
+ use Illuminate\Support\Facades\DB;
7
+
8
+ class FleetOpsStorageDeadlineConnection extends MySqlConnection
9
+ {
10
+ public array $queries = [];
11
+ public array $responses = [];
12
+ public bool $maria = false;
13
+
14
+ public function getReadPdo()
15
+ {
16
+ return new class($this->maria) extends PDO {
17
+ public function __construct(private bool $maria)
18
+ {
19
+ }
20
+
21
+ public function getAttribute(int $attribute): mixed
22
+ {
23
+ if ($attribute !== PDO::ATTR_SERVER_VERSION) {
24
+ throw new LogicException('Only the database server version should be read');
25
+ }
26
+
27
+ return $this->maria ? '10.11.8-MariaDB' : '8.0.40';
28
+ }
29
+ };
30
+ }
31
+
32
+ public function select($query, $bindings = [], $useReadPdo = true)
33
+ {
34
+ $this->queries[] = [$query, $bindings];
35
+ $response = array_shift($this->responses) ?? [];
36
+ if ($response instanceof Throwable) {
37
+ throw $response;
38
+ }
39
+
40
+ return $response;
41
+ }
42
+ }
43
+
44
+ function fleetopsStorageQueryError(int $code): QueryException
45
+ {
46
+ $error = new PDOException('Database query failed');
47
+ $error->errorInfo = ['HY000', $code, 'Database query failed'];
48
+
49
+ return new QueryException('mysql', 'select history', [], $error);
50
+ }
51
+
52
+ beforeEach(function () {
53
+ $this->originalDb = app()->bound('db') ? app('db') : null;
54
+ $this->connection = new FleetOpsStorageDeadlineConnection(new PDO('sqlite::memory:'), '', '', ['driver' => 'mysql']);
55
+ $connection = $this->connection;
56
+ app()->instance('db', new class($connection) {
57
+ public function __construct(public $database)
58
+ {
59
+ }
60
+
61
+ public function connection($name = null)
62
+ {
63
+ return $this->database;
64
+ }
65
+
66
+ public function __call($method, $args)
67
+ {
68
+ return $this->database->{$method}(...$args);
69
+ }
70
+ });
71
+ DB::clearResolvedInstance('db');
72
+ $this->controller = new SettingController();
73
+ $this->invoke = fn ($method, ...$arguments) => (new ReflectionMethod(SettingController::class, $method))->invoke($this->controller, ...$arguments);
74
+ $this->scope = fn ($query) => $query->where('company_uuid', 'company-a');
75
+ });
76
+
77
+ afterEach(function () {
78
+ if ($this->originalDb) {
79
+ app()->instance('db', $this->originalDb);
80
+ } else {
81
+ app()->forgetInstance('db');
82
+ }
83
+ DB::clearResolvedInstance('db');
84
+ });
85
+
86
+ test('storage scans enforce driver specific deadlines while preserving bindings', function (bool $maria) {
87
+ $this->connection->maria = $maria;
88
+ $this->connection->responses = [[(object) ['row_count' => '4', 'oldest' => '2026-01-01']], [(object) ['rows' => 4]]];
89
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => 4, 'oldest' => '2026-01-01']);
90
+ ($this->invoke)('storageUsageSelect', 'select * from device_events where company_uuid = ?', ['company-a'], true);
91
+ expect($this->connection->queries[0][1])->toBe(['company-a'])
92
+ ->and($this->connection->queries[0][0])->toStartWith($maria ? 'SET STATEMENT max_statement_time=1 FOR select' : 'SELECT /*+ MAX_EXECUTION_TIME(1000) */')
93
+ ->and($this->connection->queries[1][0])->toStartWith($maria ? 'SET STATEMENT max_statement_time=1 FOR EXPLAIN select' : 'EXPLAIN SELECT /*+ MAX_EXECUTION_TIME(1000) */')
94
+ ->and($this->connection->queries[1][1])->toBe(['company-a']);
95
+ })->with([false, true]);
96
+
97
+ test('timed out counts use a scoped query plan estimate instead of another history scan', function () {
98
+ $this->connection->responses = [fleetopsStorageQueryError(3024), [(object) ['rows' => 101, 'filtered' => 25]]];
99
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => 25, 'oldest' => null, 'rows_estimated' => true])
100
+ ->and($this->connection->queries[1][0])->toStartWith('EXPLAIN SELECT')
101
+ ->and($this->connection->queries[1][1])->toBe(['company-a']);
102
+ $this->connection->responses = [fleetopsStorageQueryError(1969), fleetopsStorageQueryError(1969)];
103
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => null, 'oldest' => null, 'rows_estimated' => true]);
104
+ });
105
+
106
+ test('storage queries suppress only timeout errors and retain real database failures', function () {
107
+ foreach (['tableUsage', 'tableOldest'] as $method) {
108
+ $this->connection->responses = [fleetopsStorageQueryError(1146)];
109
+ expect(fn () => ($this->invoke)($method, 'device_events', $this->scope, 'created_at'))->toThrow(QueryException::class);
110
+ }
111
+ $this->connection->responses = [fleetopsStorageQueryError(3024), fleetopsStorageQueryError(1146)];
112
+ expect(fn () => ($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toThrow(QueryException::class);
113
+ $this->connection->responses = [fleetopsStorageQueryError(3024)];
114
+ expect(($this->invoke)('tableOldest', 'device_events', $this->scope, 'created_at'))->toBeNull();
115
+ $this->connection->responses = [fleetopsStorageQueryError(1969)];
116
+ expect(($this->invoke)('storageTableStatistics', ['device_events']))->toBe([]);
117
+ $this->connection->responses = [fleetopsStorageQueryError(1146)];
118
+ expect(fn () => ($this->invoke)('storageTableStatistics', ['device_events']))->toThrow(QueryException::class);
119
+ });
@@ -269,14 +269,11 @@ test('internal fuel report controller syncs custom fields after save when provid
269
269
  $controller = new FleetOpsInternalFuelReportControllerProbe();
270
270
  $fuelReport = new FleetOpsInternalFuelReportAfterSaveFake();
271
271
 
272
- $controller->afterSave(new Request([
273
- 'fuel_report' => [
274
- 'custom_field_values' => [
275
- ['key' => 'receipt_number', 'value' => 'R-100'],
276
- ],
277
- ],
278
- ]), $fuelReport);
279
- $controller->afterSave(new Request(['fuel_report' => ['custom_field_values' => []]]), $fuelReport);
272
+ $input = ['custom_field_values' => [['key' => 'receipt_number', 'value' => 'R-100']]];
273
+ $controller->afterSave(new Request(['fuelReport' => $input]), $fuelReport, $input);
274
+ $controller->afterSave(new Request(), $fuelReport, ['custom_field_values' => []]);
275
+ $controller->afterSave(new Request(), $fuelReport, ['custom_field_values' => 'invalid']);
276
+ $controller->afterSave(new Request(), $fuelReport);
280
277
 
281
278
  expect($fuelReport->synced)->toBe([
282
279
  [
@@ -137,17 +137,24 @@ function fleetopsNavigatorCredential(string $key = 'flb_live_key'): ApiCredentia
137
137
  return $credential;
138
138
  }
139
139
 
140
+ function fleetopsNavigatorSignedQuery(): array
141
+ {
142
+ $expires = time() + 600;
143
+
144
+ return ['expires' => $expires, 'signature' => NavigatorController::linkSignature($expires)];
145
+ }
146
+
140
147
  test('navigator controller builds android and ios app link redirects', function () {
141
148
  $company = fleetopsNavigatorCompany();
142
149
  $controller = new FleetOpsNavigatorControllerProbe();
143
150
  $controller->adminUser = fleetopsNavigatorUser($company);
144
151
  $controller->apiCredential = fleetopsNavigatorCredential('flb_live_navigator');
145
152
 
146
- $android = $controller->linkApp(Request::create('/navigator/link-app', 'GET', [], [], [], [
153
+ $android = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery(), [], [], [
147
154
  'HTTP_USER_AGENT' => 'Mozilla/5.0 Android',
148
155
  ]))->getData(true);
149
156
 
150
- $ios = $controller->linkApp(Request::create('/navigator/link-app', 'GET', [], [], [], [
157
+ $ios = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery(), [], [], [
151
158
  'HTTP_USER_AGENT' => 'Mozilla/5.0 iPhone',
152
159
  ]))->getData(true);
153
160
 
@@ -169,11 +176,32 @@ test('navigator controller returns missing organization error when no admin comp
169
176
  $controller = new FleetOpsNavigatorControllerProbe();
170
177
  $controller->adminUser = fleetopsNavigatorUser(null);
171
178
 
172
- $response = $controller->linkApp(new Request());
179
+ $response = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery()));
173
180
 
174
181
  expect($response->getData(true))->toBe(['error' => 'Organization for linking not found.']);
175
182
  });
176
183
 
184
+ test('navigator controller refuses unsigned, tampered and expired app links before touching credentials', function () {
185
+ $company = fleetopsNavigatorCompany();
186
+ $controller = new FleetOpsNavigatorControllerProbe();
187
+ $controller->adminUser = fleetopsNavigatorUser($company);
188
+ $controller->apiCredential = fleetopsNavigatorCredential('flb_live_navigator');
189
+
190
+ $expired = time() - 60;
191
+ $requests = [
192
+ 'unsigned' => Request::create('/navigator/link-app', 'GET'),
193
+ 'tampered' => Request::create('/navigator/link-app', 'GET', ['signature' => 'forged'] + fleetopsNavigatorSignedQuery()),
194
+ 'expired' => Request::create('/navigator/link-app', 'GET', ['expires' => $expired, 'signature' => NavigatorController::linkSignature($expired)]),
195
+ ];
196
+
197
+ foreach ($requests as $request) {
198
+ $response = $controller->linkApp($request);
199
+ expect($response->getData(true))->toBe(['error' => 'This Navigator link is invalid or has expired. Generate a new one from the console.']);
200
+ }
201
+
202
+ expect($controller->credentialLookups)->toBe([]);
203
+ });
204
+
177
205
  test('navigator controller exposes link url settings and current organization token lookup branches', function () {
178
206
  $controller = new FleetOpsNavigatorControllerProbe();
179
207
  $controller->apiCredential = fleetopsNavigatorCredential('flb_test_key');
@@ -194,7 +222,11 @@ test('navigator controller exposes link url settings and current organization to
194
222
  ]);
195
223
  $missing = $controller->getCurrentOrganization($secretRequest);
196
224
 
197
- expect($linkUrl)->toBe(['linkUrl' => 'http://localhost/int/v1/fleet-ops/navigator/link-app'])
225
+ parse_str((string) parse_url($linkUrl['linkUrl'], PHP_URL_QUERY), $linkQuery);
226
+
227
+ expect($linkUrl['linkUrl'])->toStartWith('http://localhost/int/v1/fleet-ops/navigator/link-app?')
228
+ ->and($linkQuery['signature'])->toBe(NavigatorController::linkSignature((int) $linkQuery['expires']))
229
+ ->and((int) $linkQuery['expires'])->toBeGreaterThan(time())
198
230
  ->and($settings)->toBe(['enabled' => true, 'invite_code_required' => false])
199
231
  ->and($organization)->toBeInstanceOf(Organization::class)
200
232
  ->and($controller->credentialLookups)->toContain(
@@ -675,3 +675,34 @@ test('maintenance schedule reminder mail exposes schedule context', function ()
675
675
  'offsetDays' => 7,
676
676
  ]);
677
677
  });
678
+
679
+ test('customer credentials mail reads the portal slug from the customer company regardless of session', function (?string $sessionCompany) {
680
+ app('config')->set('fleetbase.console.host', 'console.fleetbase.test');
681
+ app('config')->set('fleetbase.console.secure', true);
682
+ app('config')->set('fleetbase.console.subdomain', null);
683
+ app('session.store')->put('company', $sessionCompany);
684
+
685
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
686
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
687
+ $resolver->setDefaultConnection('mysql');
688
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
689
+ $connection->getSchemaBuilder()->create('settings', function ($blueprint) {
690
+ $blueprint->increments('id');
691
+ $blueprint->string('key')->nullable();
692
+ $blueprint->text('value')->nullable();
693
+ $blueprint->timestamps();
694
+ });
695
+ $connection->table('settings')->insert([
696
+ ['key' => 'company.company-portal.customer-portal-config', 'value' => json_encode(['accessUrlSlug' => 'acme-portal'])],
697
+ ['key' => 'company.other-company.customer-portal-config', 'value' => json_encode(['accessUrlSlug' => 'other-portal'])],
698
+ ]);
699
+
700
+ $customer = new FleetOpsNotificationContactFake();
701
+ $customer->userForTest = new User();
702
+ $customer->userForTest->setRawAttributes(['email' => 'customer@example.test'], true);
703
+ $customer->setRawAttributes(['company_uuid' => 'company-portal'], true);
704
+
705
+ $content = fleetOpsNotificationWithEnvironment(fn () => (new CustomerCredentialsMail('plain-secret', $customer))->content());
706
+
707
+ expect($content->with['customerPortalUrl'])->toBe('https://console.fleetbase.test/acme-portal');
708
+ })->with([null, 'other-company']);
@@ -454,7 +454,7 @@ class FleetOpsNotifyDriverOnShiftChangeProbe extends NotifyDriverOnShiftChange
454
454
  return $this->schedule;
455
455
  }
456
456
 
457
- protected function getSchedulingSettings(): array
457
+ protected function getSchedulingSettings(?string $companyUuid): array
458
458
  {
459
459
  return $this->settings;
460
460
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\OrchestrationController;
4
4
  use Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry;
5
+ use Illuminate\Http\Exceptions\HttpResponseException;
6
+ use Illuminate\Http\Request;
5
7
 
6
8
  function sanitize_orchestrator_payload(array $payload): array
7
9
  {
@@ -72,3 +74,43 @@ test('consumable orchestrator routes are registered under the public api group',
72
74
  expect($routes)->toContain("\$router->post('run', 'OrchestrationController@run');");
73
75
  expect($routes)->toContain("\$router->post('commit', 'OrchestrationController@commit');");
74
76
  });
77
+
78
+ test('public orchestration uses API authentication without inheriting console IAM middleware', function () {
79
+ $registry = new OrchestrationEngineRegistry();
80
+ $public = new OrchestrationController($registry);
81
+ $internal = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrchestrationController($registry);
82
+
83
+ expect($public->getMiddleware())->toBe([]);
84
+
85
+ // An API-key request has no console IAM role. The internal workbench must
86
+ // still refuse this actor; its middleware must not leak onto the API class.
87
+ app('session.store')->flush();
88
+ $request = Request::create('/int/v1/fleet-ops/orchestrator/run', 'POST');
89
+ app()->instance('request', $request);
90
+ foreach (['run', 'commit'] as $method) {
91
+ $guard = collect($internal->getMiddleware())->first(fn ($entry) => in_array($method, $entry['options']['only'], true));
92
+ expect($guard)->not->toBeNull();
93
+
94
+ try {
95
+ $guard['middleware']($request, fn () => throw new RuntimeException('Unauthorized request reached the workbench'));
96
+ test()->fail('The internal workbench must require a permitted user');
97
+ } catch (HttpResponseException $error) {
98
+ expect($error->getResponse()->getStatusCode())->toBe(401);
99
+ }
100
+ }
101
+ });
102
+
103
+ test('public orchestration resolves tenant scope from the authenticated session, never request input', function () {
104
+ $controller = new OrchestrationController(new OrchestrationEngineRegistry());
105
+ $company = new ReflectionMethod($controller, 'companyUuid');
106
+ $company->setAccessible(true);
107
+ $request = Request::create('/v1/orchestrator/run', 'POST', ['company_uuid' => 'other-company']);
108
+ app()->instance('request', $request);
109
+
110
+ session(['company' => 'credential-company']);
111
+ expect($company->invoke($controller))->toBe('credential-company');
112
+
113
+ session(['company' => 'next-credential-company']);
114
+ expect($company->invoke($controller))->toBe('next-credential-company');
115
+ app('session.store')->flush();
116
+ });