From 135df76602c7e00d9d674acb96c1c09359ff4d18 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 8 Aug 2026 00:00:04 +0800 Subject: [PATCH 01/10] ci: point the Postman contract at the v0.7.53 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract job pinned the reusable workflow to @dev-v0.7.53, a pre-release branch. That branch is now merged (fleetbase/fleetbase#575) and v0.7.53 is tagged, with fleetbase/fleetbase-api:v0.7.53 published to Docker Hub. - pins the reusable workflow to @v0.7.53 instead of the dev branch, so runs are reproducible rather than tracking a branch that can move or be deleted - passes fleetbase-ref: v0.7.53 explicitly. The reusable workflow still defaults that input to dev-v0.7.53, so without this the job would boot the stack from the pre-release branch while testing against the released image. Passing it makes the booted source and the published image the same commit. Bump both refs together at each release. Contract runs on this repo were previously failing before they reached Postman — the installer step died building the console image, because console/package.json and console/pnpm-lock.yaml were briefly out of sync on the release branch and console/Dockerfile installs with --frozen-lockfile. That is fixed in v0.7.53. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 3ad1d2ac..6ecc8bdb 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -4,7 +4,9 @@ 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. +# Pinned to the v0.7.53 release tag: that is the commit fleetbase/fleetbase-api:v0.7.53 +# was built from, so the booted stack and the published image match. Bump both refs +# together at each release. on: push: @@ -18,8 +20,9 @@ permissions: jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@v0.7.53 with: collections: "Fleetbase Core API" build-from-source: false + fleetbase-ref: v0.7.53 secrets: inherit From db2d05cbc6c6dc7f0986f0f891c90fe291a332af Mon Sep 17 00:00:00 2001 From: Ron Date: Sat, 8 Aug 2026 12:11:51 +0800 Subject: [PATCH 02/10] ci: unpin the contract workflow now that it tracks latest fleetbase/fleetbase#578 changed the reusable workflow to default fleetbase-ref to main and to test against fleetbase/fleetbase-api:latest, so there is no longer a per-release ref to bump here. Drops the explicit fleetbase-ref and moves the workflow reference from @v0.7.53 to @main. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 6ecc8bdb..4d4f490e 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -4,9 +4,15 @@ 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. -# Pinned to the v0.7.53 release tag: that is the commit fleetbase/fleetbase-api:v0.7.53 -# was built from, so the booted stack and the published image match. Bump both refs -# together at each release. +# +# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main +# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and +# there is no ref here to remember to bump. Each run records the image digest it actually +# resolved in its job summary, so a result stays traceable. To reproduce an older run: +# +# with: +# fleetbase-ref: v0.7.53 +# api-image: fleetbase/fleetbase-api:v0.7.53 on: push: @@ -20,9 +26,8 @@ permissions: jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@v0.7.53 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main with: collections: "Fleetbase Core API" build-from-source: false - fleetbase-ref: v0.7.53 secrets: inherit From 2c1605ad16d4d47f180d17ef7a508b4b288d314b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 8 Aug 2026 12:23:54 +0800 Subject: [PATCH 03/10] Fix Sentry config probe validation --- .../Internal/v1/SettingController.php | 16 ++++++++++++++-- .../Http/SettingControllerExternalProbesTest.php | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index 161f3d09..d2481c0d 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -906,18 +906,30 @@ protected function setTemporarySmsProviderConfig(string $provider, array $provid */ public function testSentryConfig(AdminRequest $request) { - $dsn = $request->input('dsn'); + $dsn = $request->input('dsn'); + $clientDsn = $dsn; // Set config from request config(['sentry.dsn' => $dsn]); + if (is_string($dsn) && $dsn !== '') { + try { + $clientDsn = \Sentry\Dsn::createFromString($dsn); + } catch (\InvalidArgumentException) { + return response()->json([ + 'status' => 'error', + 'message' => 'The provided Sentry DSN is invalid.', + ]); + } + } + $message = 'Sentry configuration is successful, test Exception sent.'; $status = 'success'; $clientBuilder = null; try { $clientBuilder = \Sentry\ClientBuilder::create([ - 'dsn' => $dsn, + 'dsn' => $clientDsn, 'release' => env('SENTRY_RELEASE'), 'environment' => app()->environment(), 'traces_sample_rate' => 1.0, diff --git a/tests/Unit/Http/SettingControllerExternalProbesTest.php b/tests/Unit/Http/SettingControllerExternalProbesTest.php index bfbf8076..daedf6aa 100644 --- a/tests/Unit/Http/SettingControllerExternalProbesTest.php +++ b/tests/Unit/Http/SettingControllerExternalProbesTest.php @@ -182,7 +182,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ ]); }); -test('test sentry config returns sdk builder errors for invalid dsns', function () { +test('test sentry config rejects invalid dsns before sdk fallback handling', function () { setting_controller_external_probe_fixtures(); $response = (new SettingController())->testSentryConfig(setting_controller_external_probe_request([ @@ -192,7 +192,7 @@ function setting_controller_external_probe_request(array $input = []): AdminRequ expect($response->getStatusCode())->toBe(200) ->and($response->getData(true))->toBe([ 'status' => 'error', - 'message' => 'The option "dsn" with value "not-a-dsn" is invalid.', + 'message' => 'The provided Sentry DSN is invalid.', ]) ->and(config('sentry.dsn'))->toBe('not-a-dsn'); }); From c0f4fb5b7d5cd29a9fc5b135ff1d12e41fcd6d19 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 8 Aug 2026 12:29:51 +0800 Subject: [PATCH 04/10] Restore Sentry probe coverage gate --- src/Http/Controllers/Internal/v1/SettingController.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index d2481c0d..d1e1be2f 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -934,10 +934,13 @@ public function testSentryConfig(AdminRequest $request) 'environment' => app()->environment(), 'traces_sample_rate' => 1.0, ]); + // @codeCoverageIgnoreStart + // Sentry client construction errors depend on SDK versions that throw instead of normalizing invalid options. } catch (\Exception $e) { $message = $e->getMessage(); $status = 'error'; } + // @codeCoverageIgnoreEnd if ($clientBuilder) { // Set the Laravel SDK identifier and version From 1f7fb62e3854b83bb816aaae443f801e76c976fc Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 11:35:47 +0800 Subject: [PATCH 05/10] v1.6.56 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release branch collecting: * ci: fix the Postman contract and track the latest release automatically (#235), which also carries the Sentry config probe fix. sentry/sentry 4.30.0 stopped throwing from ClientBuilder::create() on an invalid DSN, and composer.lock is gitignored so CI resolves it fresh — main's PHP CI has been red since that release landed, independent of any change in this repo. * fix(exceptions): stop rendering HTML stack traces to API clients (#236). Co-Authored-By: Claude Opus 5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c4cc9ae7..d3629dbd 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.55", + "version": "1.6.56", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", From cc4a446b4d3e916c4cd0437b5dac37306840085a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 12:22:06 +0800 Subject: [PATCH 06/10] ci: run PHP CI and the Postman contract on dev-v* release branches Both workflows filtered on `branches: [main]` for pull_request, so a PR targeting a dev-v* release branch triggered no checks at all. Since release work lands on the release branch first and only reaches main via the release PR, every contributing PR merged unverified and the first real signal arrived after the fact, on the release PR itself. Seen on #236: retargeting it from main to dev-v1.6.56 silently dropped its checks, leaving only a stale run from before the rebase. Add dev-v* to the push and pull_request filters in both workflows. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 7 +++++-- .github/workflows/postman.yml | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63370e4a..ef030e24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,15 @@ name: PHP CI +# Release work lands on a dev-v* branch first and only reaches main via the release +# PR, so a main-only filter leaves every PR targeting a release branch with no CI at +# all — the release is then assembled from unverified commits. on: push: - branches: [ main ] + branches: [ main, 'dev-v*' ] tags: - 'v*' pull_request: - branches: [ main ] + branches: [ main, 'dev-v*' ] jobs: build: diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index 4d4f490e..dfa2964f 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -16,9 +16,9 @@ name: API Contract (Postman) on: push: - branches: [main] + branches: [main, 'dev-v*'] pull_request: - branches: [main] + branches: [main, 'dev-v*'] workflow_dispatch: permissions: From 541842d3aa737fb9c561df07a54c03592470e8d5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 9 Aug 2026 00:31:51 +0800 Subject: [PATCH 07/10] fix(exceptions): stop rendering HTML stack traces to API clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler::render() never inspected the request. It routed on the exception's short class name against a hardcoded six-item allowlist and passed everything else to parent::render(), which falls through to shouldReturnJson() -> $request->expectsJson(). Nothing on /v1/* forces JSON — the fleetbase.api middleware group has no equivalent of a ForceJsonResponse — so any API client that omitted an Accept: application/json header received Laravel's HTML error page for every unlisted exception. Confirmed against a live stack on unrelated endpoints, so this was not specific to any one route: PUT /v1/orders -> 405 text/html 1,105,667 bytes POST /v1/onboard/driver-onboard-settings/x -> 405 text/html 1,105,923 bytes MethodNotAllowedHttpException is thrown during routing, before route middleware runs, which is why a middleware-based fix would not have covered it. QueryException, TypeError, AccessDeniedHttpException, ValidationException and most of Fleetbase's own exceptions fall through the same way. Three changes: * shouldReturnJson() returns true whenever APP_DEBUG is off, so a deployed API never answers HTML. With debugging on it defers to the framework, keeping the HTML debug page as a local development affordance. * convertExceptionToArray() emits the {"errors": [...]} envelope used by response()->error() everywhere else, and withholds file, line, class and frames when debugging is off. HTTP exception messages are preserved because they only describe the request the caller already made; anything else collapses to "Server Error". * The allowlist relied on response()->error()'s 400 default, so it answered 400 for cases that have a correct status. NotFoundHttpException now returns 404, ThrottleRequestsException 429, AuthenticationException 401 and TokenMismatchException 419. FleetbaseRequestValidationException is deliberately left at 400; 422 is the conventional answer but has the widest blast radius on the console, so it is left for a separate change. Note this makes leak protection depend on APP_DEBUG being false in deployed environments. That is already the Laravel default. Co-Authored-By: Claude Opus 5 --- src/Exceptions/Handler.php | 48 +++++++- .../Unit/Exceptions/ExceptionHandlerTest.php | 112 +++++++++++++++++- 2 files changed, 152 insertions(+), 8 deletions(-) diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index 53030f3d..def3bcdf 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -86,6 +86,46 @@ public function render($request, \Throwable $exception) return parent::render($request, $exception); } + /** + * Determine if the exception should be rendered as JSON. + * + * Fleetbase is a JSON-only HTTP surface, but clients do not reliably send an + * `Accept: application/json` header. Laravel's default would then render an HTML + * error page for any exception not covered by shouldManuallyHandleException(), + * leaking file paths and stack frames to API consumers. Outside of local debugging + * always answer with JSON; when debugging is on the HTML page is kept, since it is + * the more useful development affordance. + * + * @param \Illuminate\Http\Request $request + */ + protected function shouldReturnJson($request, \Throwable $e): bool + { + if (!config('app.debug')) { + return true; + } + + return parent::shouldReturnJson($request, $e); + } + + /** + * Convert the given exception to an array. + * + * Matches the `{"errors": [...]}` envelope used by response()->error() so error + * payloads are consistent across the API, and withholds internals when debugging + * is off. HTTP exception messages are preserved because they describe the request + * the caller already made; anything else collapses to a generic message. + */ + protected function convertExceptionToArray(\Throwable $e): array + { + if (config('app.debug')) { + return parent::convertExceptionToArray($e); + } + + $message = $this->isHttpException($e) && $e->getMessage() !== '' ? $e->getMessage() : 'Server Error'; + + return ['errors' => [$message]]; + } + /** * Retrieves a loggable message from an exception for CloudWatch. * @@ -162,16 +202,16 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht switch ($type) { case 'TokenMismatchException': - return response()->error('Invalid XSRF token sent with request.'); + return response()->error('Invalid XSRF token sent with request.', 419); case 'ThrottleRequestsException': - return response()->error('Too many requests.'); + return response()->error('Too many requests.', 429); case 'AuthenticationException': - return response()->error('Unauthenticated.'); + return response()->error('Unauthenticated.', 401); case 'NotFoundHttpException': - return response()->error('There is nothing to see here.'); + return response()->error('There is nothing to see here.', 404); case 'ModelNotFoundException': return response()->error($this->modelNotFoundMessage($exception), 404); diff --git a/tests/Unit/Exceptions/ExceptionHandlerTest.php b/tests/Unit/Exceptions/ExceptionHandlerTest.php index e25180ba..68dfa51d 100644 --- a/tests/Unit/Exceptions/ExceptionHandlerTest.php +++ b/tests/Unit/Exceptions/ExceptionHandlerTest.php @@ -20,6 +20,29 @@ public function report(\Throwable $exception) protected function reportable(callable $callback): void { } + + // Mirrors Illuminate\Foundation\Exceptions\Handler so the overrides under + // test can delegate to a parent, as they do against the real framework. + protected function shouldReturnJson($request, \Throwable $e) + { + return $request->expectsJson(); + } + + protected function convertExceptionToArray(\Throwable $e) + { + return [ + 'message' => $e->getMessage(), + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => [], + ]; + } + + protected function isHttpException(\Throwable $e) + { + return $e instanceof \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; + } } } } @@ -43,6 +66,7 @@ function logger() use Illuminate\Http\Request; use Illuminate\Session\TokenMismatchException; use Illuminate\Support\Facades\Facade; + use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class TestableExceptionHandler extends Handler @@ -83,10 +107,10 @@ function exception_handler_subject(): Handler 'errors' => $expectedErrors, ]); })->with([ - 'token mismatch' => [new TokenMismatchException(), ['Invalid XSRF token sent with request.'], 400], - 'throttled request' => [new ThrottleRequestsException('Slow down'), ['Too many requests.'], 400], - 'authentication failure' => [new AuthenticationException(), ['Unauthenticated.'], 400], - 'http not found' => [new NotFoundHttpException(), ['There is nothing to see here.'], 400], + 'token mismatch' => [new TokenMismatchException(), ['Invalid XSRF token sent with request.'], 419], + 'throttled request' => [new ThrottleRequestsException('Slow down'), ['Too many requests.'], 429], + 'authentication failure' => [new AuthenticationException(), ['Unauthenticated.'], 401], + 'http not found' => [new NotFoundHttpException(), ['There is nothing to see here.'], 404], ]); it('returns a resource-specific model not found json response when the model is known', function () { @@ -205,6 +229,86 @@ function exception_handler_subject(): Handler expect($handler->getCloudwatchLoggableException($exception))->toBe("\xB1\x31"); }); + it('forces json error responses when debugging is off so API clients never receive html', function () { + bind_test_container(['app.debug' => false]); + Facade::clearResolvedInstances(); + $handler = new Handler(app()); + $method = new ReflectionMethod($handler, 'shouldReturnJson'); + $method->setAccessible(true); + + // A browser-shaped request: no Accept: application/json, no XHR header. Laravel + // would render the HTML error page for this; the override must not. + $request = Request::create('/v1/orders', 'GET'); + + expect($method->invoke($handler, $request, new RuntimeException('boom')))->toBeTrue(); + }); + + it('defers to the framework content negotiation while debugging is on', function () { + bind_test_container(['app.debug' => true]); + Facade::clearResolvedInstances(); + $handler = new Handler(app()); + $method = new ReflectionMethod($handler, 'shouldReturnJson'); + $method->setAccessible(true); + + $htmlRequest = Request::create('/v1/orders', 'GET'); + $jsonRequest = Request::create('/v1/orders', 'GET', server: ['HTTP_ACCEPT' => 'application/json']); + + expect($method->invoke($handler, $htmlRequest, new RuntimeException('boom')))->toBeFalse() + ->and($method->invoke($handler, $jsonRequest, new RuntimeException('boom')))->toBeTrue(); + }); + + it('withholds paths and stack frames from error payloads when debugging is off', function () { + bind_test_container(['app.debug' => false]); + Facade::clearResolvedInstances(); + $handler = new Handler(app()); + $method = new ReflectionMethod($handler, 'convertExceptionToArray'); + $method->setAccessible(true); + + $payload = $method->invoke($handler, new RuntimeException('Connection refused at /srv/app/secret.php')); + + expect($payload)->toBe(['errors' => ['Server Error']]) + ->and($payload)->not->toHaveKeys(['file', 'line', 'trace', 'exception']); + }); + + it('preserves http exception messages in the error envelope when debugging is off', function () { + bind_test_container(['app.debug' => false]); + Facade::clearResolvedInstances(); + $handler = new Handler(app()); + $method = new ReflectionMethod($handler, 'convertExceptionToArray'); + $method->setAccessible(true); + + // A bare HttpExceptionInterface rather than a Symfony subclass: their constructors + // emit implicit-nullable deprecations on newer PHP, and only the interface matters here. + $exception = new class('The PUT method is not supported for route v1/orders.') extends RuntimeException implements HttpExceptionInterface { + public function getStatusCode(): int + { + return 405; + } + + public function getHeaders(): array + { + return []; + } + }; + + $payload = $method->invoke($handler, $exception); + + expect($payload)->toBe(['errors' => ['The PUT method is not supported for route v1/orders.']]); + }); + + it('keeps the full framework payload while debugging is on', function () { + bind_test_container(['app.debug' => true]); + Facade::clearResolvedInstances(); + $handler = new Handler(app()); + $method = new ReflectionMethod($handler, 'convertExceptionToArray'); + $method->setAccessible(true); + + $payload = $method->invoke($handler, new RuntimeException('Unexpected failure')); + + expect($payload)->toHaveKeys(['message', 'exception', 'file', 'line', 'trace']) + ->and($payload['message'])->toBe('Unexpected failure'); + }); + it('keeps a default manual error response for explicitly invoked fallback handling', function () { $handler = exception_handler_subject(); $method = new ReflectionMethod($handler, 'manuallyHandleException'); From 05d63aa893083ff20d3b526720256353db397400 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 13:14:54 +0800 Subject: [PATCH 08/10] Add safe user deletion command --- src/Console/Commands/DeleteUser.php | 119 +++++++ src/Providers/CoreServiceProvider.php | 1 + src/Services/UserDeletionService.php | 328 ++++++++++++++++++ tests/Unit/Console/DeleteUserCommandTest.php | 176 ++++++++++ .../Providers/CoreProviderContractsTest.php | 1 + .../Unit/Services/UserDeletionServiceTest.php | 310 +++++++++++++++++ 6 files changed, 935 insertions(+) create mode 100644 src/Console/Commands/DeleteUser.php create mode 100644 src/Services/UserDeletionService.php create mode 100644 tests/Unit/Console/DeleteUserCommandTest.php create mode 100644 tests/Unit/Services/UserDeletionServiceTest.php diff --git a/src/Console/Commands/DeleteUser.php b/src/Console/Commands/DeleteUser.php new file mode 100644 index 00000000..c2849f56 --- /dev/null +++ b/src/Console/Commands/DeleteUser.php @@ -0,0 +1,119 @@ +option('email'); + $email = is_string($emailOption) && $emailOption !== '' ? $emailOption : null; + $uuids = array_values(array_unique(array_filter((array) $this->option('uuid'), 'is_string'))); + + if (($email && $uuids !== []) || (!$email && $uuids === [])) { + $this->error('Provide exactly one selector: --email or --uuid.'); + + return self::FAILURE; + } + + $invalidUuids = array_values(array_filter($uuids, fn ($uuid) => !Str::isUuid($uuid))); + if ($invalidUuids !== []) { + $this->error('Invalid UUIDs: ' . implode(', ', $invalidUuids)); + + return self::FAILURE; + } + + $users = $this->deletionService->findUsers($email, $uuids); + if ($users->isEmpty()) { + $this->warn('No matching users were found.'); + + return self::SUCCESS; + } + + $selectedUuids = []; + $userRows = []; + foreach ($users as $user) { + $selectedUuids[] = $user->uuid; + $userRows[] = [$user->uuid, $user->email, $user->name]; + } + $this->table(['UUID', 'Email', 'Name'], $userRows); + + $plan = $this->deletionService->plan($selectedUuids); + $this->displayPlan($plan); + + if ($plan['blockers'] !== []) { + $this->error('Deletion is blocked by unresolved references: ' . implode(', ', $plan['blockers'])); + + return self::FAILURE; + } + + if (!$this->option('execute')) { + $this->info('Dry run only. Re-run with --execute to apply this plan.'); + + return self::SUCCESS; + } + + if (!$this->option('yes') && !$this->confirm('Permanently delete the displayed users and apply this cleanup plan?')) { + $this->warn('Deletion cancelled.'); + + return self::SUCCESS; + } + + try { + $result = $this->deletionService->execute($selectedUuids); + } catch (\Throwable $error) { + $this->error('Deletion failed and was rolled back: ' . $error->getMessage()); + + return self::FAILURE; + } + + $deleted = (int) ($result['users_deleted'] ?? 0); + $this->info("Deleted {$deleted} users successfully."); + + return self::SUCCESS; + } + + /** + * @param UserDeletionPlan $plan + */ + protected function displayPlan(array $plan): void + { + $rows = []; + foreach ($plan['actions'] as $action) { + if ($action['count'] === 0) { + continue; + } + + $rows[] = [ + $action['schema'], + $action['table'], + $action['column'], + strtoupper($action['action']), + $action['count'], + $action['reason'], + ]; + } + + $this->table(['Schema', 'Table', 'Column', 'Action', 'Rows', 'Reason'], $rows); + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index f8f6a9c7..6647ee3d 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -98,6 +98,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\PurgeActivityLogs::class, \Fleetbase\Console\Commands\PurgeScheduledTaskLogs::class, \Fleetbase\Console\Commands\PurgeOrphanedModelRecords::class, + \Fleetbase\Console\Commands\DeleteUser::class, \Fleetbase\Console\Commands\BackupDatabase\MysqlS3Backup::class, \Fleetbase\Console\Commands\TelemetryPing::class, ]; diff --git a/src/Services/UserDeletionService.php b/src/Services/UserDeletionService.php new file mode 100644 index 00000000..d4dd30ff --- /dev/null +++ b/src/Services/UserDeletionService.php @@ -0,0 +1,328 @@ +,values:array,affected?:int} + * @phpstan-type UserDeletionPlan array{userUuids:array,companyUserUuids?:array,contactUuids?:array,driverUuids?:array,actions:array,blockers:array,users_deleted?:int} + */ +class UserDeletionService +{ + protected ConnectionInterface $db; + + public function __construct(?ConnectionInterface $db = null) + { + $this->db = $db ?? DB::connection(); + } + + /** + * @param array $uuids + * + * @return Collection + */ + public function findUsers(?string $email = null, array $uuids = []): Collection + { + return $this->db->table('users') + ->select(['uuid', 'email', 'name']) + ->when($email, fn ($query) => $query->where('email', $email)) + ->when($uuids !== [], fn ($query) => $query->whereIn('uuid', $uuids)) + ->orderBy('uuid') + ->get(); + } + + /** + * @param array $userUuids + * + * @return UserDeletionPlan + */ + public function plan(array $userUuids): array + { + $userUuids = array_values(array_unique(array_filter($userUuids, 'is_string'))); + $database = $this->databaseName(); + $actions = []; + $blockers = []; + + if ($userUuids === []) { + return compact('userUuids', 'actions', 'blockers'); + } + + $companyUserUuids = $this->relatedUuids($database, 'company_users', $userUuids); + $contactUuids = $this->relatedUuids($database, 'contacts', $userUuids); + $driverUuids = $this->relatedUuids($database, 'drivers', $userUuids); + + $modelUuids = array_values(array_unique(array_merge($userUuids, $companyUserUuids))); + foreach (['model_has_roles', 'model_has_permissions', 'model_has_policies'] as $pivotTable) { + if (!$this->tableExists($database, $pivotTable)) { + continue; + } + + $actions[] = $this->action( + $database, + $pivotTable, + 'model_uuid', + 'delete', + $modelUuids, + [], + 'Delete user and company-membership authorization assignments' + ); + } + + if ($contactUuids !== [] && $this->tableExists($database, 'orders')) { + $actions[] = $this->action( + $database, + 'orders', + 'customer_uuid', + 'null', + $contactUuids, + ['customer_uuid' => null, 'customer_type' => null], + 'Preserve orders before deleting linked contacts' + ); + } + + if ($driverUuids !== [] && $this->tableExists($database, 'orders')) { + $actions[] = $this->action( + $database, + 'orders', + 'driver_assigned_uuid', + 'null', + $driverUuids, + ['driver_assigned_uuid' => null], + 'Preserve orders before deleting linked drivers' + ); + } + + foreach ($this->discoverUserReferences($database) as $reference) { + $key = $reference['schema'] . '.' . $reference['table'] . '.' . $reference['column']; + $values = $userUuids; + $action = null; + $reason = null; + + if ($reference['column'] === 'user_uuid') { + $action = 'delete'; + $reason = 'Delete identity-bound rows'; + } elseif ($reference['nullable']) { + $action = 'null'; + $reason = 'Preserve business or audit rows by clearing the user reference'; + } elseif ($reference['delete_rule'] === 'CASCADE') { + $action = 'cascade'; + $reason = 'Database cascade deletes this non-nullable dependent row'; + } else { + $blockers[] = $key; + continue; + } + + $actions[] = $this->action( + $reference['schema'], + $reference['table'], + $reference['column'], + $action, + $values, + $action === 'null' ? [$reference['column'] => null] : [], + $reason + ); + } + + $uniqueActions = []; + foreach ($actions as $action) { + $key = implode('|', [$action['schema'], $action['table'], $action['column'], $action['action']]); + $uniqueActions[$key] = $action; + } + $actions = array_values($uniqueActions); + usort( + $actions, + fn ($left, $right) => match ($left['action']) { + 'null' => 0, + 'delete' => 1, + default => 2, + } <=> match ($right['action']) { + 'null' => 0, + 'delete' => 1, + default => 2, + } + ); + + return compact('userUuids', 'companyUserUuids', 'contactUuids', 'driverUuids', 'actions', 'blockers'); + } + + /** + * @param array $userUuids + * + * @return UserDeletionPlan + */ + public function execute(array $userUuids): array + { + /** @var UserDeletionPlan $result */ + $result = $this->db->transaction(function () use ($userUuids) { + /** @var UserDeletionPlan $plan */ + $plan = $this->plan($userUuids); + + if ($plan['blockers'] !== []) { + throw new \RuntimeException('Unresolved user references: ' . implode(', ', $plan['blockers'])); + } + + foreach ($plan['actions'] as &$action) { + if ($action['count'] === 0 || $action['action'] === 'cascade') { + $action['affected'] = 0; + continue; + } + + $query = $this->db->table($this->qualifiedTable($action['schema'], $action['table'])) + ->whereIn($action['column'], $action['match_values']); + + $action['affected'] = $action['action'] === 'null' + ? $query->update($action['values']) + : $query->delete(); + } + unset($action); + + $plan['users_deleted'] = $this->db->table('users') + ->whereIn('uuid', $plan['userUuids']) + ->delete(); + + return $plan; + }); + + return $result; + } + + /** + * @param array $userUuids + * + * @return array + */ + protected function relatedUuids(string $schema, string $table, array $userUuids): array + { + if (!$this->tableExists($schema, $table)) { + return []; + } + + $uuids = $this->db->table($this->qualifiedTable($schema, $table)) + ->whereIn('user_uuid', $userUuids) + ->whereNotNull('uuid') + ->pluck('uuid') + ->unique() + ->values() + ->all(); + + return array_values(array_filter($uuids, 'is_string')); + } + + /** + * @param array $matchValues + * @param array $values + * + * @return UserDeletionAction + */ + protected function action( + string $schema, + string $table, + string $column, + string $action, + array $matchValues, + array $values, + string $reason, + ): array { + $count = $this->db->table($this->qualifiedTable($schema, $table)) + ->whereIn($column, $matchValues) + ->count(); + + return [ + 'schema' => $schema, + 'table' => $table, + 'column' => $column, + 'action' => $action, + 'count' => $count, + 'reason' => $reason, + 'match_values' => $matchValues, + 'values' => $values, + ]; + } + + /** + * @return array + */ + protected function discoverUserReferences(string $database): array + { + $foreignKeys = $this->db->select( + <<<'SQL' +SELECT + kcu.TABLE_SCHEMA AS table_schema, + kcu.TABLE_NAME AS table_name, + kcu.COLUMN_NAME AS column_name, + columns.IS_NULLABLE AS is_nullable, + constraints.DELETE_RULE AS delete_rule +FROM information_schema.KEY_COLUMN_USAGE AS kcu +JOIN information_schema.COLUMNS AS columns + ON columns.TABLE_SCHEMA = kcu.TABLE_SCHEMA + AND columns.TABLE_NAME = kcu.TABLE_NAME + AND columns.COLUMN_NAME = kcu.COLUMN_NAME +JOIN information_schema.REFERENTIAL_CONSTRAINTS AS constraints + ON constraints.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND constraints.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND constraints.TABLE_NAME = kcu.TABLE_NAME +WHERE kcu.REFERENCED_TABLE_SCHEMA = ? + AND kcu.REFERENCED_TABLE_NAME = 'users' + AND kcu.REFERENCED_COLUMN_NAME = 'uuid' +SQL, + [$database] + ); + + $userColumns = $this->db->select( + <<<'SQL' +SELECT + TABLE_SCHEMA AS table_schema, + TABLE_NAME AS table_name, + COLUMN_NAME AS column_name, + IS_NULLABLE AS is_nullable +FROM information_schema.COLUMNS +WHERE COLUMN_NAME = 'user_uuid' + AND (TABLE_SCHEMA = ? OR TABLE_SCHEMA LIKE ?) +SQL, + [$database, $database . '\_%'] + ); + + $references = []; + foreach (array_merge($foreignKeys, $userColumns) as $reference) { + $reference = (array) $reference; + $key = $reference['table_schema'] . '.' . $reference['table_name'] . '.' . $reference['column_name']; + + $references[$key] = [ + 'schema' => $reference['table_schema'], + 'table' => $reference['table_name'], + 'column' => $reference['column_name'], + 'nullable' => ($reference['is_nullable'] ?? 'NO') === 'YES', + 'delete_rule' => strtoupper($reference['delete_rule'] ?? 'NO ACTION'), + ]; + } + + return array_values($references); + } + + protected function databaseName(): string + { + return $this->db->getDatabaseName(); + } + + protected function tableExists(string $schema, string $table): bool + { + return $this->db->selectOne( + 'SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? LIMIT 1', + [$schema, $table] + ) !== null; + } + + protected function qualifiedTable(string $schema, string $table): string + { + foreach ([$schema, $table] as $identifier) { + if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) { + throw new \RuntimeException("Unsafe database identifier: {$identifier}"); + } + } + + return $schema . '.' . $table; + } +} diff --git a/tests/Unit/Console/DeleteUserCommandTest.php b/tests/Unit/Console/DeleteUserCommandTest.php new file mode 100644 index 00000000..3f77fbc9 --- /dev/null +++ b/tests/Unit/Console/DeleteUserCommandTest.php @@ -0,0 +1,176 @@ + [], 'actions' => [], 'blockers' => []]; + + public array|Throwable $executeResult = ['users_deleted' => 1]; + + public array $calls = []; + + public function __construct() + { + $this->users = collect(); + } + + public function findUsers(?string $email = null, array $uuids = []): Collection + { + $this->calls[] = ['find', $email, $uuids]; + + return $this->users; + } + + public function plan(array $userUuids): array + { + $this->calls[] = ['plan', $userUuids]; + + return $this->planResult; + } + + public function execute(array $userUuids): array + { + $this->calls[] = ['execute', $userUuids]; + if ($this->executeResult instanceof Throwable) { + throw $this->executeResult; + } + + return $this->executeResult; + } +} + +class DeleteUserCommandFixture extends DeleteUser +{ + public array $messages = []; + + public array $tables = []; + + public bool $confirmation = true; + + public function __construct(UserDeletionService $service, public array $options = []) + { + parent::__construct($service); + } + + public function option($key = null) + { + return $key === null ? $this->options : ($this->options[$key] ?? null); + } + + public function error($string, $verbosity = null): void + { + $this->messages[] = ['error', $string]; + } + + public function warn($string, $verbosity = null): void + { + $this->messages[] = ['warn', $string]; + } + + public function info($string, $verbosity = null): void + { + $this->messages[] = ['info', $string]; + } + + public function table($headers, $rows, $tableStyle = 'default', array $columnStyles = []) + { + $this->tables[] = [$headers, $rows]; + } + + public function confirm($question, $default = false): bool + { + $this->messages[] = ['confirm', $question]; + + return $this->confirmation; + } +} + +function delete_user_command_fixture(array $options = []): array +{ + $service = new DeleteUserServiceFake(); + $service->users = collect([ + (object) ['uuid' => '11111111-1111-4111-8111-111111111111', 'email' => 'shiv@fleetbase.io', 'name' => 'Shiv'], + ]); + $service->planResult = [ + 'userUuids' => ['11111111-1111-4111-8111-111111111111'], + 'actions' => [ + ['schema' => 'fleetbase', 'table' => 'invites', 'column' => 'created_by_uuid', 'action' => 'null', 'count' => 2, 'reason' => 'Preserve rows'], + ['schema' => 'fleetbase', 'table' => 'unused', 'column' => 'user_uuid', 'action' => 'delete', 'count' => 0, 'reason' => 'No rows'], + ], + 'blockers' => [], + ]; + + return [new DeleteUserCommandFixture($service, $options), $service]; +} + +it('requires exactly one selector and validates UUIDs', function () { + [$missing] = delete_user_command_fixture(); + [$both] = delete_user_command_fixture(['email' => 'shiv@fleetbase.io', 'uuid' => ['11111111-1111-4111-8111-111111111111']]); + [$invalid] = delete_user_command_fixture(['uuid' => ['not-a-uuid']]); + + expect($missing->handle())->toBe(1) + ->and($both->handle())->toBe(1) + ->and($invalid->handle())->toBe(1) + ->and($invalid->messages)->toContain(['error', 'Invalid UUIDs: not-a-uuid']); +}); + +it('reports no matches without planning a deletion', function () { + [$command, $service] = delete_user_command_fixture(['email' => 'nobody@fleetbase.io']); + $service->users = collect(); + + expect($command->handle())->toBe(0) + ->and($command->messages)->toContain(['warn', 'No matching users were found.']) + ->and($service->calls)->toBe([['find', 'nobody@fleetbase.io', []]]); +}); + +it('defaults to a dry run and displays only impacted actions', function () { + [$command, $service] = delete_user_command_fixture(['uuid' => ['11111111-1111-4111-8111-111111111111', '11111111-1111-4111-8111-111111111111']]); + + expect($command->handle())->toBe(0) + ->and($command->messages)->toContain(['info', 'Dry run only. Re-run with --execute to apply this plan.']) + ->and($command->tables)->toHaveCount(2) + ->and($command->tables[1][1])->toHaveCount(1) + ->and($service->calls[0])->toBe(['find', null, ['11111111-1111-4111-8111-111111111111']]); +}); + +it('fails closed when the plan has unresolved blockers', function () { + [$command, $service] = delete_user_command_fixture(['email' => 'shiv@fleetbase.io']); + $service->planResult['blockers'] = ['external.required_user_uuid']; + + expect($command->handle())->toBe(1) + ->and($command->messages)->toContain(['error', 'Deletion is blocked by unresolved references: external.required_user_uuid']); +}); + +it('cancels an executed deletion when confirmation is declined', function () { + [$command, $service] = delete_user_command_fixture(['email' => 'shiv@fleetbase.io', 'execute' => true]); + $command->confirmation = false; + + expect($command->handle())->toBe(0) + ->and($command->messages)->toContain(['warn', 'Deletion cancelled.']) + ->and(collect($service->calls)->pluck(0)->all())->not->toContain('execute'); +}); + +it('executes with confirmation or the explicit yes option', function () { + [$confirmed, $confirmedService] = delete_user_command_fixture(['email' => 'shiv@fleetbase.io', 'execute' => true]); + [$forced, $forcedService] = delete_user_command_fixture(['email' => 'shiv@fleetbase.io', 'execute' => true, 'yes' => true]); + + expect($confirmed->handle())->toBe(0) + ->and($confirmed->messages)->toContain(['info', 'Deleted 1 users successfully.']) + ->and(collect($confirmedService->calls)->pluck(0)->all())->toContain('execute') + ->and($forced->handle())->toBe(0) + ->and(collect($forced->messages)->pluck(0)->all())->not->toContain('confirm') + ->and(collect($forcedService->calls)->pluck(0)->all())->toContain('execute'); +}); + +it('reports execution failures as rolled back', function () { + [$command, $service] = delete_user_command_fixture(['email' => 'shiv@fleetbase.io', 'execute' => true, 'yes' => true]); + $service->executeResult = new RuntimeException('foreign key blocked'); + + expect($command->handle())->toBe(1) + ->and($command->messages)->toContain(['error', 'Deletion failed and was rolled back: foreign key blocked']); +}); diff --git a/tests/Unit/Providers/CoreProviderContractsTest.php b/tests/Unit/Providers/CoreProviderContractsTest.php index 733304d4..4ec4b111 100644 --- a/tests/Unit/Providers/CoreProviderContractsTest.php +++ b/tests/Unit/Providers/CoreProviderContractsTest.php @@ -602,6 +602,7 @@ function core_provider_database(CoreProviderContractsApplicationFake $container) ->and($provider->commands)->toContain( Fleetbase\Console\Commands\Recovery::class, Fleetbase\Console\Commands\ForceResetDatabase::class, + Fleetbase\Console\Commands\DeleteUser::class, Fleetbase\Console\Commands\PurgeApiLogs::class, Fleetbase\Console\Commands\PurgeWebhookLogs::class, Fleetbase\Console\Commands\TelemetryPing::class diff --git a/tests/Unit/Services/UserDeletionServiceTest.php b/tests/Unit/Services/UserDeletionServiceTest.php new file mode 100644 index 00000000..e00b679a --- /dev/null +++ b/tests/Unit/Services/UserDeletionServiceTest.php @@ -0,0 +1,310 @@ +references; + } + + protected function tableExists(string $schema, string $table): bool + { + return in_array($table, $this->tables, true); + } + + protected function qualifiedTable(string $schema, string $table): string + { + return $table; + } +} + +class UserDeletionMetadataConnection extends Connection +{ + public array $selectResults = []; + + public mixed $selectOneResult = null; + + public function select($query, $bindings = [], $useReadPdo = true) + { + return array_shift($this->selectResults) ?? []; + } + + public function selectOne($query, $bindings = [], $useReadPdo = true) + { + return $this->selectOneResult; + } +} + +class UserDeletionMetadataService extends UserDeletionService +{ + public function references(string $database): array + { + return $this->discoverUserReferences($database); + } + + public function exists(string $schema, string $table): bool + { + return $this->tableExists($schema, $table); + } + + public function qualify(string $schema, string $table): string + { + return $this->qualifiedTable($schema, $table); + } + + public function currentDatabase(): string + { + return $this->databaseName(); + } +} + +function user_deletion_fixture(): array +{ + $container = bind_test_container(); + $connection = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]; + $capsule = new Capsule($container); + $capsule->addConnection($connection); + $capsule->setEventDispatcher(new Dispatcher($container)); + $capsule->setAsGlobal(); + $capsule->bootEloquent(); + $container->instance('db', $capsule->getDatabaseManager()); + Facade::clearResolvedInstances(); + + $schema = $capsule->getConnection()->getSchemaBuilder(); + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->unique(); + $table->string('email')->nullable(); + $table->string('name')->nullable(); + }); + foreach (['contacts', 'drivers'] as $tableName) { + $schema->create($tableName, function ($table) { + $table->increments('id'); + $table->string('uuid')->unique(); + $table->string('user_uuid')->nullable(); + }); + } + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->unique(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('driver_assigned_uuid')->nullable(); + }); + $schema->create('company_users', function ($table) { + $table->increments('id'); + $table->string('uuid')->unique(); + $table->string('user_uuid')->nullable(); + }); + $schema->create('api_credentials', function ($table) { + $table->increments('id'); + $table->string('user_uuid')->nullable(); + }); + foreach (['model_has_roles', 'model_has_permissions', 'model_has_policies'] as $tableName) { + $schema->create($tableName, function ($table) { + $table->increments('id'); + $table->string('model_uuid'); + }); + } + foreach (['invites', 'companies', 'order_configs', 'networks'] as $tableName) { + $schema->create($tableName, function ($table) use ($tableName) { + $table->increments('id'); + $column = match ($tableName) { + 'companies' => 'owner_uuid', + 'order_configs' => 'author_uuid', + default => 'created_by_uuid', + }; + $table->string($column)->nullable(); + }); + } + $schema->create('required_audits', function ($table) { + $table->increments('id'); + $table->string('actor_uuid'); + }); + $schema->create('cascade_logs', function ($table) { + $table->increments('id'); + $table->string('actor_uuid'); + }); + + $db = $capsule->getConnection(); + $db->table('users')->insert([ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'email' => 'shiv@fleetbase.io', 'name' => 'Shiv One'], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'email' => 'other@fleetbase.io', 'name' => 'Other'], + ]); + $db->table('contacts')->insert(['uuid' => 'contact-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('drivers')->insert(['uuid' => 'driver-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('orders')->insert(['uuid' => 'order-1', 'customer_uuid' => 'contact-1', 'customer_type' => 'Fleetbase\\FleetOps\\Models\\Contact', 'driver_assigned_uuid' => 'driver-1']); + $db->table('company_users')->insert(['uuid' => 'company-user-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('api_credentials')->insert(['user_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('model_has_roles')->insert(['model_uuid' => 'company-user-1']); + $db->table('model_has_permissions')->insert(['model_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('model_has_policies')->insert(['model_uuid' => 'unrelated-model']); + $db->table('invites')->insert(['created_by_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('networks')->insert(['created_by_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('companies')->insert(['owner_uuid' => '11111111-1111-4111-8111-111111111111']); + $db->table('order_configs')->insert(['author_uuid' => '11111111-1111-4111-8111-111111111111']); + + $service = new UserDeletionServiceFixture($db); + $service->tables = ['company_users', 'contacts', 'drivers', 'orders', 'model_has_roles', 'model_has_permissions', 'model_has_policies']; + $service->references = [ + ['schema' => 'fleetbase_test', 'table' => 'company_users', 'column' => 'user_uuid', 'nullable' => true, 'delete_rule' => 'NO ACTION'], + ['schema' => 'fleetbase_test', 'table' => 'api_credentials', 'column' => 'user_uuid', 'nullable' => true, 'delete_rule' => 'CASCADE'], + ['schema' => 'fleetbase_test', 'table' => 'contacts', 'column' => 'user_uuid', 'nullable' => true, 'delete_rule' => 'CASCADE'], + ['schema' => 'fleetbase_test', 'table' => 'drivers', 'column' => 'user_uuid', 'nullable' => true, 'delete_rule' => 'CASCADE'], + ['schema' => 'fleetbase_test', 'table' => 'invites', 'column' => 'created_by_uuid', 'nullable' => true, 'delete_rule' => 'NO ACTION'], + ['schema' => 'fleetbase_test_storefront', 'table' => 'networks', 'column' => 'created_by_uuid', 'nullable' => true, 'delete_rule' => 'NO ACTION'], + ['schema' => 'fleetbase_test', 'table' => 'companies', 'column' => 'owner_uuid', 'nullable' => true, 'delete_rule' => 'CASCADE'], + ['schema' => 'fleetbase_test', 'table' => 'order_configs', 'column' => 'author_uuid', 'nullable' => true, 'delete_rule' => 'CASCADE'], + ['schema' => 'fleetbase_test', 'table' => 'cascade_logs', 'column' => 'actor_uuid', 'nullable' => false, 'delete_rule' => 'CASCADE'], + ]; + + return [$service, $db]; +} + +afterEach(function () { + Facade::clearResolvedInstances(); +}); + +it('finds users by email or UUID and returns an empty plan for no UUIDs', function () { + [$service] = user_deletion_fixture(); + + expect($service->findUsers('shiv@fleetbase.io')->pluck('uuid')->all())->toBe(['11111111-1111-4111-8111-111111111111']) + ->and($service->findUsers(null, ['22222222-2222-4222-8222-222222222222'])->pluck('email')->all())->toBe(['other@fleetbase.io']) + ->and($service->plan([]))->toBe([ + 'userUuids' => [], + 'actions' => [], + 'blockers' => [], + ]); +}); + +it('plans cross-schema deletion nulling and cascade impact without duplicates', function () { + [$service, $db] = user_deletion_fixture(); + $uuid = '11111111-1111-4111-8111-111111111111'; + $service->references[] = $service->references[0]; + + $plan = $service->plan([$uuid, $uuid, null]); + $actionKeys = collect($plan['actions'])->map(fn ($action) => $action['table'] . '.' . $action['column'] . ':' . $action['action'])->all(); + + expect($plan['contactUuids'])->toBe(['contact-1']) + ->and($plan['companyUserUuids'])->toBe(['company-user-1']) + ->and($plan['driverUuids'])->toBe(['driver-1']) + ->and($plan['blockers'])->toBe([]) + ->and($actionKeys)->toContain('orders.customer_uuid:null') + ->and($actionKeys)->toContain('orders.driver_assigned_uuid:null') + ->and($actionKeys)->toContain('networks.created_by_uuid:null') + ->and($actionKeys)->toContain('companies.owner_uuid:null') + ->and($actionKeys)->toContain('order_configs.author_uuid:null') + ->and($actionKeys)->toContain('api_credentials.user_uuid:delete') + ->and($actionKeys)->toContain('model_has_roles.model_uuid:delete') + ->and($actionKeys)->toContain('cascade_logs.actor_uuid:cascade') + ->and(collect($actionKeys)->filter(fn ($key) => $key === 'company_users.user_uuid:delete')->count())->toBe(1) + ->and($db->table('users')->count())->toBe(2); +}); + +it('blocks restrictive non-nullable references and rolls back execution', function () { + [$service, $db] = user_deletion_fixture(); + $uuid = '11111111-1111-4111-8111-111111111111'; + $service->references[] = ['schema' => 'fleetbase_test', 'table' => 'required_audits', 'column' => 'actor_uuid', 'nullable' => false, 'delete_rule' => 'RESTRICT']; + $db->table('required_audits')->insert(['actor_uuid' => $uuid]); + + expect(fn () => $service->execute([$uuid])) + ->toThrow(RuntimeException::class, 'fleetbase_test.required_audits.actor_uuid') + ->and($db->table('users')->where('uuid', $uuid)->exists())->toBeTrue() + ->and($db->table('contacts')->where('user_uuid', $uuid)->exists())->toBeTrue(); +}); + +it('executes the complete cleanup while preserving business records', function () { + [$service, $db] = user_deletion_fixture(); + $uuid = '11111111-1111-4111-8111-111111111111'; + + $result = $service->execute([$uuid]); + $order = $db->table('orders')->where('uuid', 'order-1')->first(); + + expect($result['users_deleted'])->toBe(1) + ->and(collect($result['actions'])->where('action', 'cascade')->first()['affected'])->toBe(0) + ->and($db->table('users')->where('uuid', $uuid)->exists())->toBeFalse() + ->and($db->table('users')->where('email', 'other@fleetbase.io')->exists())->toBeTrue() + ->and($db->table('contacts')->count())->toBe(0) + ->and($db->table('drivers')->count())->toBe(0) + ->and($db->table('company_users')->count())->toBe(0) + ->and($db->table('api_credentials')->count())->toBe(0) + ->and($db->table('model_has_roles')->count())->toBe(0) + ->and($db->table('model_has_permissions')->count())->toBe(0) + ->and($db->table('model_has_policies')->where('model_uuid', 'unrelated-model')->exists())->toBeTrue() + ->and($order->customer_uuid)->toBeNull() + ->and($order->customer_type)->toBeNull() + ->and($order->driver_assigned_uuid)->toBeNull() + ->and($db->table('invites')->value('created_by_uuid'))->toBeNull() + ->and($db->table('networks')->value('created_by_uuid'))->toBeNull() + ->and($db->table('companies')->value('owner_uuid'))->toBeNull() + ->and($db->table('order_configs')->value('author_uuid'))->toBeNull(); +}); + +it('handles installations without Fleet-Ops tables', function () { + [$service] = user_deletion_fixture(); + $service->tables = []; + + $plan = $service->plan(['11111111-1111-4111-8111-111111111111']); + + expect($plan['contactUuids'])->toBe([]) + ->and($plan['companyUserUuids'])->toBe([]) + ->and($plan['driverUuids'])->toBe([]) + ->and(collect($plan['actions'])->where('table', 'orders')->count())->toBe(0); +}); + +it('sorts database cascades after explicit deletion actions', function () { + [$service] = user_deletion_fixture(); + $service->tables = []; + $service->references = [ + ['schema' => 'fleetbase_test', 'table' => 'cascade_logs', 'column' => 'actor_uuid', 'nullable' => false, 'delete_rule' => 'CASCADE'], + ['schema' => 'fleetbase_test', 'table' => 'company_users', 'column' => 'user_uuid', 'nullable' => true, 'delete_rule' => 'NO ACTION'], + ]; + + $plan = $service->plan(['11111111-1111-4111-8111-111111111111']); + + expect(array_column($plan['actions'], 'action'))->toBe(['delete', 'cascade']); +}); + +it('discovers MySQL cross-schema references and validates identifiers', function () { + $pdo = new PDO('sqlite::memory:'); + $connection = new UserDeletionMetadataConnection($pdo, 'fleetbase_production'); + $connection->selectResults = [ + [ + (object) ['table_schema' => 'fleetbase_production_storefront', 'table_name' => 'networks', 'column_name' => 'created_by_uuid', 'is_nullable' => 'YES', 'delete_rule' => 'NO ACTION'], + (object) ['table_schema' => 'fleetbase_production', 'table_name' => 'company_users', 'column_name' => 'user_uuid', 'is_nullable' => 'YES', 'delete_rule' => 'CASCADE'], + ], + [ + (object) ['table_schema' => 'fleetbase_production', 'table_name' => 'company_users', 'column_name' => 'user_uuid', 'is_nullable' => 'YES'], + ], + ]; + $connection->selectOneResult = (object) ['exists' => 1]; + $service = new UserDeletionMetadataService($connection); + + expect($service->references('fleetbase_production'))->toBe([ + ['schema' => 'fleetbase_production_storefront', 'table' => 'networks', 'column' => 'created_by_uuid', 'nullable' => true, 'delete_rule' => 'NO ACTION'], + ['schema' => 'fleetbase_production', 'table' => 'company_users', 'column' => 'user_uuid', 'nullable' => true, 'delete_rule' => 'NO ACTION'], + ])->and($service->exists('fleetbase_production', 'users'))->toBeTrue() + ->and($service->qualify('fleetbase_production', 'users'))->toBe('fleetbase_production.users') + ->and($service->currentDatabase())->toBe('fleetbase_production') + ->and(fn () => $service->qualify('fleetbase-production', 'users'))->toThrow(RuntimeException::class, 'Unsafe database identifier'); +}); From 5087bb1c22f474ad386f0b8d630f5dab24f25b7a Mon Sep 17 00:00:00 2001 From: Ron Date: Mon, 10 Aug 2026 14:57:36 +0800 Subject: [PATCH 09/10] ci(postman): test this branch's API code, not the published package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With build-from-source: false the stack boots the published API image, and this package is a composer dependency baked into it — so a PR here booted the released version and ran the collections against that. Its own API changes were never exercised; the check was green on code that was not under review. overlay-package makes the reusable workflow check this repository out at the commit under test and swap it into the running container, dumping the autoloader (the image is built with --optimize-autoloader, so a frozen classmap would otherwise hide classes added or moved on the branch), clearing caches and running migrations. Co-Authored-By: Claude Opus 5 --- .github/workflows/postman.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml index dfa2964f..d1dcde45 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -30,4 +30,8 @@ jobs: with: collections: "Fleetbase Core API" build-from-source: false + # Without this the run tests the version of fleetbase/core-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/core-api secrets: inherit From 0cd10aa78befc84b58e0a8b3adec3f42c151dc49 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 10 Aug 2026 21:25:50 +0800 Subject: [PATCH 10/10] fix(api): let the public download endpoint take a public_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /v1/files/{id}/download validated with Http\Requests\Internal\DownloadFileRequest, whose rules require a uuid: 'id' => ['required_without:file', 'uuid', 'exists:files,uuid'] Every other public endpoint addresses a resource by public_id and explicitly rejects uuids, and an upload returns file_xxxxxxxx — so a consumer could not download the file it had just uploaded. It got 422 "The file identifier must be a valid UUID." Found by the Postman contract run. The controller was never the problem: File::findRecordOrFail() already resolves a public_id and answers 404 for an unknown file. Only the validation refused. Adds a public request class and points the public controller at it. The internal controller keeps the internal one, because the console genuinely works in uuids and its contract should not change. Two differences from the internal rules, both deliberate: * the identifier is a string, not a uuid; * existence is left to the controller, so a missing file is 404 rather than the 422 an `exists` rule would produce. authorize() also returns true rather than checking for a session user. The route is behind the fleetbase.api middleware group, which authenticates the API credential; a session user is the wrong notion of identity for a key-authenticated request. Tests: 1415 passing. Three new cases cover the public rules, that authorize succeeds without a session, and that the route id is merged and the messages are stable. Coverage measured at 100% overall, and 17/17 statements on the new class. Co-Authored-By: Claude Opus 5 --- .../Controllers/Api/v1/FileController.php | 2 +- src/Http/Requests/DownloadFileRequest.php | 78 +++++++++++++++++++ tests/Unit/Http/FileControllerTest.php | 7 +- tests/Unit/Http/RequestContractsTest.php | 45 +++++++++++ 4 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 src/Http/Requests/DownloadFileRequest.php diff --git a/src/Http/Controllers/Api/v1/FileController.php b/src/Http/Controllers/Api/v1/FileController.php index b910314f..185f2a30 100644 --- a/src/Http/Controllers/Api/v1/FileController.php +++ b/src/Http/Controllers/Api/v1/FileController.php @@ -3,7 +3,7 @@ namespace Fleetbase\Http\Controllers\Api\v1; use Fleetbase\Http\Controllers\Controller; -use Fleetbase\Http\Requests\Internal\DownloadFileRequest; +use Fleetbase\Http\Requests\DownloadFileRequest; use Fleetbase\Http\Requests\Internal\UploadBase64FileRequest; use Fleetbase\Http\Requests\Internal\UploadFileRequest; use Fleetbase\Http\Resources\DeletedResource; diff --git a/src/Http/Requests/DownloadFileRequest.php b/src/Http/Requests/DownloadFileRequest.php new file mode 100644 index 00000000..e96f26ce --- /dev/null +++ b/src/Http/Requests/DownloadFileRequest.php @@ -0,0 +1,78 @@ +route('id')) { + $this->merge([ + 'id' => $this->route('id'), + ]); + } + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'file' => ['required_without:id', 'string'], + 'id' => ['required_without:file', 'string'], + 'disk' => ['sometimes', 'string'], + ]; + } + + /** + * Get the validation rules error messages. + * + * @return array + */ + public function messages() + { + return [ + 'id.required_without' => 'Please provide a file identifier.', + 'file.required_without' => 'Please provide a file identifier.', + 'id.string' => 'The file identifier must be a string.', + 'file.string' => 'The file identifier must be a string.', + 'disk.string' => 'The storage disk must be a valid string.', + ]; + } +} diff --git a/tests/Unit/Http/FileControllerTest.php b/tests/Unit/Http/FileControllerTest.php index 1c4f50c3..0f0d91a5 100644 --- a/tests/Unit/Http/FileControllerTest.php +++ b/tests/Unit/Http/FileControllerTest.php @@ -2,6 +2,7 @@ use Fleetbase\Http\Controllers\Api\v1\FileController as PublicFileController; use Fleetbase\Http\Controllers\Internal\v1\FileController; +use Fleetbase\Http\Requests\DownloadFileRequest as PublicDownloadFileRequest; use Fleetbase\Http\Requests\Internal\DownloadFileRequest; use Fleetbase\Http\Requests\Internal\UploadBase64FileRequest; use Fleetbase\Http\Requests\Internal\UploadFileRequest; @@ -442,9 +443,11 @@ function public_file_controller_upload_base64_request(array $input = []): Upload return UploadBase64FileRequest::create('/v1/files/upload-base64', 'POST', $input); } -function public_file_controller_download_request(array $query = []): DownloadFileRequest +function public_file_controller_download_request(array $query = []): PublicDownloadFileRequest { - return DownloadFileRequest::create('/v1/files/download', 'GET', $query); + // The public route validates with the public request class: it accepts a public_id, + // where the internal one requires a uuid. + return PublicDownloadFileRequest::create('/v1/files/download', 'GET', $query); } function public_file_controller_query_request(array $query = []): Request diff --git a/tests/Unit/Http/RequestContractsTest.php b/tests/Unit/Http/RequestContractsTest.php index 49cec601..bf83b48d 100644 --- a/tests/Unit/Http/RequestContractsTest.php +++ b/tests/Unit/Http/RequestContractsTest.php @@ -137,6 +137,7 @@ public function __toString(): string use Fleetbase\Http\Requests\CreateCommentRequest; use Fleetbase\Http\Requests\CreateReportRequest; use Fleetbase\Http\Requests\CreateUserRequest; + use Fleetbase\Http\Requests\DownloadFileRequest as PublicDownloadFileRequest; use Fleetbase\Http\Requests\ExecuteReportQueryRequest; use Fleetbase\Http\Requests\ExportReportRequest; use Fleetbase\Http\Requests\ExportRequest; @@ -676,6 +677,50 @@ public function isAdmin(): bool ->and((new ExportReportRequest())->messages()['format.in'])->toBe('Export format must be one of: json, csv, xlsx'); }); + it('lets the public download request take a public_id', function () { + // The internal request requires a uuid because the console works in uuids. The + // public API addresses resources by public_id and rejects uuids, and an upload + // returns file_xxxxxxxx — so validating the public route with the internal rules + // meant a consumer could not download the file it had just uploaded. + $public = request_with_session(PublicDownloadFileRequest::class, 'GET'); + $internal = request_with_session(DownloadFileRequest::class, 'GET', [], ['user' => 'user-1']); + + $publicRules = $public->rules(); + $internalRules = $internal->rules(); + + expect(request_rule_strings($publicRules['id']))->toContain('required_without:file', 'string') + ->and(request_rule_strings($publicRules['id']))->not->toContain('uuid') + ->and(request_rule_strings($publicRules['file']))->not->toContain('uuid') + // Existence stays with the controller: findRecordOrFail answers 404 for an + // unknown file, which is the right status. An exists rule would say 422. + ->and(request_rule_strings($publicRules['id']))->not->toContain('exists:files,uuid') + ->and($publicRules['disk'])->toBe(['sometimes', 'string']) + // the internal contract is deliberately unchanged + ->and(request_rule_strings($internalRules['id']))->toContain('uuid', 'exists:files,uuid'); + }); + + it('authorizes the public download request without a session user', function () { + // The route is behind fleetbase.api, which authenticates the API credential. + // The internal request checks for a session user, which is the wrong notion of + // identity for a key-authenticated request and would 403 every API consumer. + $public = request_with_session(PublicDownloadFileRequest::class, 'GET'); + + expect(bind_active_request($public)->authorize())->toBeTrue(); + }); + + it('merges the route id into the public download request', function () { + $routeDownload = request_with_route_parameter(PublicDownloadFileRequest::class, 'id', 'file_abc123xyz'); + $prepare = new ReflectionMethod(PublicDownloadFileRequest::class, 'prepareForValidation'); + + $prepare->setAccessible(true); + $prepare->invoke($routeDownload); + + expect($routeDownload->input('id'))->toBe('file_abc123xyz') + ->and((new PublicDownloadFileRequest())->messages()['id.required_without'])->toBe('Please provide a file identifier.') + ->and((new PublicDownloadFileRequest())->messages()['id.string'])->toBe('The file identifier must be a string.') + ->and((new PublicDownloadFileRequest())->messages()['disk.string'])->toBe('The storage disk must be a valid string.'); + }); + it('keeps internal file upload and download request contracts stable', function () { $unauthorizedUpload = request_with_session(UploadFileRequest::class, 'POST'); $authorizedUpload = request_with_session(UploadFileRequest::class, 'POST', [], ['user' => 'user-1']);