diff --git a/app/Console/Commands/Mapledeploy/UserDelete.php b/app/Console/Commands/Mapledeploy/UserDelete.php new file mode 100644 index 000000000..7c12c9945 --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserDelete.php @@ -0,0 +1,61 @@ +argument('user_id'); + $userId = filter_var($rawUserId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if ($userId === false) { + return $this->failWith('INVALID_USER_ID'); + } + + if ($userId === 0) { + return $this->failWith('CANNOT_DELETE_ROOT_USER'); + } + + $user = User::find($userId); + if (! $user) { + $this->line(json_encode([ + 'deleted' => null, + 'alreadyDeleted' => true, + 'id' => $userId, + ], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + $deleted = [ + 'id' => $user->id, + 'email' => $user->email, + ]; + + DB::transaction(function () use ($user) { + $user->tokens()->delete(); + // MapleDeploy branding: deletion must end any active browser sessions. + DB::table('sessions')->where('user_id', $user->id)->delete(); + $user->delete(); + }); + + $this->line(json_encode(['deleted' => $deleted], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + private function failWith(string $code): int + { + $this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR)); + + return self::FAILURE; + } +} diff --git a/tests/Feature/MapledeployUserManagementCommandsTest.php b/tests/Feature/MapledeployUserManagementCommandsTest.php index f1fcf9706..ae45a2081 100644 --- a/tests/Feature/MapledeployUserManagementCommandsTest.php +++ b/tests/Feature/MapledeployUserManagementCommandsTest.php @@ -238,6 +238,75 @@ function runMapledeployUserCommand(array $arguments, string $stdin = ''): array ->and($memberUser->fresh()->remember_token)->toBeNull(); }); +test('MapleDeploy user delete command removes non-root users', function () { + runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--admin', + '--email=owner@example.com', + '--name=Owner', + ], "owner-password\n"); + + $member = runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--email=delete-me@example.com', + '--name=Delete Me', + '--team-role=admin', + ], "member-password\n"); + $memberUser = User::findOrFail($member['json']['user']['id']); + + DB::table('personal_access_tokens')->insert([ + 'tokenable_type' => User::class, + 'tokenable_id' => $memberUser->id, + 'name' => 'delete-token', + 'token' => hash('sha256', 'delete-token'), + 'team_id' => '0', + 'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('sessions')->insert([ + 'id' => 'delete-session', + 'user_id' => $memberUser->id, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test Browser', + 'payload' => base64_encode('delete-payload'), + 'last_activity' => now()->timestamp, + ]); + + $deleteRoot = runMapledeployUserCommand(['mapledeploy:user:delete', '0']); + expect($deleteRoot['exitCode'])->toBe(1) + ->and($deleteRoot['json'])->toBe(['error' => 'CANNOT_DELETE_ROOT_USER']); + + $invalid = runMapledeployUserCommand(['mapledeploy:user:delete', 'not-a-user-id']); + expect($invalid['exitCode'])->toBe(1) + ->and($invalid['json'])->toBe(['error' => 'INVALID_USER_ID']); + + $delete = runMapledeployUserCommand([ + 'mapledeploy:user:delete', + (string) $memberUser->id, + ]); + + expect($delete['exitCode'])->toBe(0) + ->and($delete['json']['deleted'])->toBe([ + 'id' => $memberUser->id, + 'email' => 'delete-me@example.com', + ]) + ->and(User::find($memberUser->id))->toBeNull() + ->and(DB::table('personal_access_tokens')->where('tokenable_id', $memberUser->id)->count())->toBe(0) + ->and(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0); + + $missing = runMapledeployUserCommand([ + 'mapledeploy:user:delete', + (string) $memberUser->id, + ]); + expect($missing['exitCode'])->toBe(0) + ->and($missing['json'])->toBe([ + 'deleted' => null, + 'alreadyDeleted' => true, + 'id' => $memberUser->id, + ]); +}); + test('MapleDeploy password command can transfer root ownership identity', function () { runMapledeployUserCommand([ 'mapledeploy:user:create',