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 3ad1d2ac..d1dcde45 100644 --- a/.github/workflows/postman.yml +++ b/.github/workflows/postman.yml @@ -4,13 +4,21 @@ name: API Contract (Postman) # collection against the live API. Delegates to the reusable workflow in # fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN # (inherited); no-ops until POSTMAN_API_KEY is set. -# TODO: change @dev-v0.7.53 to @main once that branch is merged. +# +# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main +# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and +# there is no ref here to remember to bump. Each run records the image digest it actually +# resolved in its job summary, so a result stays traceable. To reproduce an older run: +# +# with: +# fleetbase-ref: v0.7.53 +# api-image: fleetbase/fleetbase-api:v0.7.53 on: push: - branches: [main] + branches: [main, 'dev-v*'] pull_request: - branches: [main] + branches: [main, 'dev-v*'] workflow_dispatch: permissions: @@ -18,8 +26,12 @@ permissions: jobs: contract: - uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@dev-v0.7.53 + uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main with: collections: "Fleetbase 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 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", 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/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/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/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index 161f3d09..d1e1be2f 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -906,26 +906,41 @@ 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, ]); + // @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 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/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/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'); 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']); 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'); }); 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'); +});