0]); $this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); $this->rootUser = User::factory()->create(['id' => 0]); fakeOpenStripeSubscriptions(); }); function deleteAccountUser(): User { $user = User::factory()->create(['name' => 'Delete Me', 'password' => bcrypt('password')]); test()->actingAs($user); return $user; } it('deletes the account and the personal team, then logs the user out', function () { $user = deleteAccountUser(); $personalTeam = $user->teams()->first(); Livewire::test(ProfileIndex::class) ->call('deleteAccount', 'password') ->assertRedirect(route('login')); expect(User::find($user->id))->toBeNull() ->and(Team::find($personalTeam->id))->toBeNull() ->and(DB::table('team_user')->where('user_id', $user->id)->exists())->toBeFalse() ->and(auth()->check())->toBeFalse(); }); it('does not delete the account with a wrong password', function () { $user = deleteAccountUser(); Livewire::test(ProfileIndex::class) ->call('deleteAccount', 'wrong-password') ->assertHasErrors('password'); expect(User::find($user->id))->not->toBeNull(); }); it('never deletes the root user', function () { expect(app(DeleteUserAccount::class)->blockers($this->rootUser)) ->toBe(['The root user cannot be deleted.']); expect(fn () => app(DeleteUserAccount::class)->handle($this->rootUser)) ->toThrow(RuntimeException::class, 'The root user cannot be deleted.'); expect(User::find(0))->not->toBeNull(); }); it('blocks deletion while a single-member team still has projects', function () { $user = deleteAccountUser(); $personalTeam = $user->teams()->first(); Project::factory()->create(['team_id' => $personalTeam->id]); Livewire::test(ProfileIndex::class) ->assertSee('Delete all projects, servers, and Git sources') ->call('deleteAccount', 'password'); expect(User::find($user->id))->not->toBeNull() ->and(Team::find($personalTeam->id))->not->toBeNull(); }); it('transfers team ownership to an admin and keeps the shared team', function () { $user = deleteAccountUser(); $admin = User::factory()->create(); $sharedTeam = Team::factory()->create(); $sharedTeam->members()->attach($user->id, ['role' => 'owner']); $sharedTeam->members()->attach($admin->id, ['role' => 'admin']); app(DeleteUserAccount::class)->handle($user); expect(User::find($user->id))->toBeNull() ->and(Team::find($sharedTeam->id))->not->toBeNull() ->and($admin->roleInTeam($sharedTeam->id))->toBe('owner'); }); it('blocks deletion when a shared team has no other owner or admin', function () { $user = deleteAccountUser(); $member = User::factory()->create(); $sharedTeam = Team::factory()->create(['name' => 'Shared']); $sharedTeam->members()->attach($user->id, ['role' => 'owner']); $sharedTeam->members()->attach($member->id, ['role' => 'member']); expect(app(DeleteUserAccount::class)->blockers($user)) ->toBe(['Make another member of the team "Shared" an admin or owner.']); }); it('removes a member from other teams without changing those teams', function () { $owner = User::factory()->create(); $user = deleteAccountUser(); $sharedTeam = Team::factory()->create(); $sharedTeam->members()->attach($owner->id, ['role' => 'owner']); $sharedTeam->members()->attach($user->id, ['role' => 'member']); $this->rootTeam->members()->attach($user->id, ['role' => 'member']); app(DeleteUserAccount::class)->handle($user); expect(User::find($user->id))->toBeNull() ->and($sharedTeam->members()->pluck('users.id')->all())->toBe([$owner->id]) ->and($this->rootTeam->members()->pluck('users.id')->all())->toBe([$this->rootUser->id]); }); it('cancels the subscription immediately before deleting the account on cloud', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $personalTeam = $user->teams()->first(); $personalTeam->update(['name' => 'Paid']); Subscription::create([ 'team_id' => $personalTeam->id, 'stripe_subscription_id' => 'sub_123', 'stripe_customer_id' => 'cus_123', 'stripe_invoice_paid' => true, ]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldReceive('retrieve')->with('sub_123')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_123', 'status' => 'active'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_123', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_123', 'status' => 'canceled'])); app()->instance(StripeClient::class, $stripe); Livewire::test(ProfileIndex::class) ->assertSee('will be cancelled immediately. This is required.') ->call('deleteAccount', 'password', []) ->assertRedirect(route('login')); expect(User::find($user->id))->toBeNull() ->and(Team::find($personalTeam->id))->toBeNull() ->and(Subscription::where('stripe_subscription_id', 'sub_123')->exists())->toBeFalse() ->and(Subscription::where('team_id', $personalTeam->id)->exists())->toBeFalse(); }); it('keeps the account and subscription when immediate cancellation fails', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $team = $user->teams()->first(); $subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_failed', 'stripe_invoice_paid' => true]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldReceive('retrieve')->with('sub_failed')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_failed', 'status' => 'active'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_failed', [])->andThrow(new RuntimeException('Stripe unavailable')); app()->instance(StripeClient::class, $stripe); expect(fn () => app(DeleteUserAccount::class)->handle($user))->toThrow(RuntimeException::class); expect(User::find($user->id))->not->toBeNull() ->and(Team::find($team->id))->not->toBeNull() ->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy(); }); it('does not cancel subscriptions before resolving account deletion blockers', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $team = $user->teams()->first(); Project::factory()->create(['team_id' => $team->id]); $subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_blocked', 'stripe_invoice_paid' => true]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldNotReceive('cancel'); app()->instance(StripeClient::class, $stripe); expect(fn () => app(DeleteUserAccount::class)->handle($user))->toThrow(RuntimeException::class); expect(User::find($user->id))->not->toBeNull() ->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy(); }); it('preserves subscriptions of other teams and teams where the user is not an owner', function (string $role) { config(['constants.coolify.self_hosted' => false]); $owner = User::factory()->create(); $team = $owner->teams()->first(); $user = deleteAccountUser(); $team->members()->attach($user->id, ['role' => $role]); $subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_other', 'stripe_invoice_paid' => true]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldNotReceive('cancel'); app()->instance(StripeClient::class, $stripe); app(DeleteUserAccount::class)->handle($user); expect(User::find($user->id))->toBeNull() ->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy(); })->with(['member', 'admin']); it('does not cancel a subscription with an incorrect password', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $subscription = Subscription::create(['team_id' => $user->teams()->first()->id, 'stripe_subscription_id' => 'sub_password', 'stripe_invoice_paid' => true]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldNotReceive('cancel'); app()->instance(StripeClient::class, $stripe); Livewire::test(ProfileIndex::class)->call('deleteAccount', 'wrong-password')->assertHasErrors('password'); expect(User::find($user->id))->not->toBeNull() ->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy(); }); it('cancels a past due subscription and preserves a transferred team', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $admin = User::factory()->create(); $team = $user->teams()->first(); $team->members()->attach($admin->id, ['role' => 'admin']); $subscription = Subscription::create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_past_due', 'stripe_invoice_paid' => false, 'stripe_past_due' => true]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldReceive('retrieve')->with('sub_past_due')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_past_due', 'status' => 'past_due'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_past_due', [])->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_past_due', 'status' => 'canceled'])); app()->instance(StripeClient::class, $stripe); app(DeleteUserAccount::class)->handle($user); expect(User::find($user->id))->toBeNull() ->and(Team::find($team->id))->not->toBeNull() ->and($admin->roleInTeam($team->id))->toBe('owner') ->and($subscription->fresh()->stripe_subscription_id)->toBeNull() ->and($subscription->fresh()->stripe_past_due)->toBeFalsy(); }); it('does not cancel the root team subscription when another owner deletes their account', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $this->rootTeam->members()->attach($user->id, ['role' => 'owner']); $subscription = Subscription::create(['team_id' => 0, 'stripe_subscription_id' => 'sub_root', 'stripe_invoice_paid' => true]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldNotReceive('cancel'); app()->instance(StripeClient::class, $stripe); app(DeleteUserAccount::class)->handle($user); expect(User::find($user->id))->toBeNull() ->and($subscription->fresh()->stripe_invoice_paid)->toBeTruthy() ->and(Team::find(0))->not->toBeNull(); }); it('removes the data of single-member teams only when asked and keeps shared team data', function () { $user = deleteAccountUser(); $personalTeam = $user->teams()->first(); $personalProject = Project::factory()->create(['team_id' => $personalTeam->id]); $personalKey = PrivateKey::factory()->create(['team_id' => $personalTeam->id]); $personalServer = Server::factory()->create(['team_id' => $personalTeam->id, 'private_key_id' => $personalKey->id]); $admin = User::factory()->create(); $sharedTeam = Team::factory()->create(); $sharedTeam->members()->attach($user->id, ['role' => 'owner']); $sharedTeam->members()->attach($admin->id, ['role' => 'admin']); $sharedProject = Project::factory()->create(['team_id' => $sharedTeam->id]); $sharedKey = PrivateKey::factory()->create(['team_id' => $sharedTeam->id]); $sharedServer = Server::factory()->create(['team_id' => $sharedTeam->id, 'private_key_id' => $sharedKey->id]); expect(fn () => app(DeleteUserAccount::class)->handle($user))->toThrow(RuntimeException::class); expect(Project::find($personalProject->id))->not->toBeNull(); app(DeleteUserAccount::class)->handle($user, removeTeamResources: true); expect(User::find($user->id))->toBeNull() ->and(Team::find($personalTeam->id))->toBeNull() ->and(Project::find($personalProject->id))->toBeNull() ->and(Server::withTrashed()->find($personalServer->id))->toBeNull() ->and(Project::find($sharedProject->id))->not->toBeNull() ->and(Server::find($sharedServer->id))->not->toBeNull() ->and($admin->roleInTeam($sharedTeam->id))->toBe('owner'); }); it('forgets busy github runners without cleanup instead of blocking the removal of team data', function () { Bus::fake(); $user = deleteAccountUser(); $team = $user->teams()->first(); $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); $githubApp = GithubApp::create([ 'name' => 'Runner App', 'team_id' => $team->id, 'private_key_id' => $privateKey->id, 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'is_public' => false, ]); $config = GithubRunnerConfig::create(['server_id' => $server->id, 'github_app_id' => $githubApp->id, 'labels' => ['coolify'], 'is_enabled' => true]); $running = GithubRunnerExecution::create(['github_app_id' => $githubApp->id, 'github_runner_config_id' => $config->id, 'server_id' => $server->id, 'trigger_workflow_job_id' => 1, 'status' => GithubRunnerStatus::Running]); $queued = GithubRunnerExecution::create(['github_app_id' => $githubApp->id, 'trigger_workflow_job_id' => 2, 'status' => GithubRunnerStatus::Queued]); app(DeleteUserAccount::class)->handle($user, removeTeamResources: true); Bus::assertNotDispatched(CleanupGithubRunnerJob::class); Bus::assertNotDispatchedSync(CleanupGithubRunnerJob::class); expect(User::find($user->id))->toBeNull() ->and(GithubApp::find($githubApp->id))->toBeNull() ->and(Server::withTrashed()->find($server->id))->toBeNull() ->and(GithubRunnerExecution::find($running->id))->toBeNull() ->and(GithubRunnerExecution::find($queued->id))->toBeNull() ->and(GithubRunnerConfig::find($config->id))->toBeNull(); }); it('lets a cloud owner whose subscription ended delete the account with the servers and projects of the personal team', function () { config(['constants.coolify.self_hosted' => false]); Process::fake(); $user = deleteAccountUser(); $team = $user->teams()->first(); $team->update(['name' => 'Lapsed']); Subscription::factory()->create(['team_id' => $team->id, 'stripe_invoice_paid' => false]); $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id]); $project = Project::factory()->create(['team_id' => $team->id]); Livewire::test(ProfileIndex::class) ->assertDontSee('Before you can delete your account') ->assertSee('The servers, projects, and Git sources of the team "Lapsed" will be removed from Coolify.', false) ->call('deleteAccount', 'password') ->assertRedirect(route('login')); expect(User::find($user->id))->toBeNull() ->and(Team::find($team->id))->toBeNull() ->and(Server::withTrashed()->find($server->id))->toBeNull() ->and(Project::find($project->id))->toBeNull(); Process::assertNothingRan(); }); it('lets a lapsed cloud owner delete the account when Stripe already ended a leftover subscription', function () { config(['constants.coolify.self_hosted' => false]); Process::fake(); $user = deleteAccountUser(); $team = $user->teams()->first(); Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_fraud_refunded', 'stripe_invoice_paid' => false]); Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_duplicate', 'stripe_invoice_paid' => false]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldReceive('retrieve')->with('sub_fraud_refunded')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_fraud_refunded', 'status' => 'canceled'])); $stripe->subscriptions->shouldReceive('retrieve')->with('sub_duplicate')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_duplicate', 'status' => 'unpaid'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_duplicate', []); app()->instance(StripeClient::class, $stripe); Livewire::test(ProfileIndex::class) ->call('deleteAccount', 'password') ->assertRedirect(route('login')); expect(User::find($user->id))->toBeNull() ->and(Team::find($team->id))->toBeNull() ->and(Subscription::where('team_id', $team->id)->exists())->toBeFalse(); }); it('still asks a cloud owner with a paying subscription to delete servers and projects first', function (array $subscription) { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $team = $user->teams()->first(); $team->update(['name' => 'Paying']); Subscription::factory()->create(['team_id' => $team->id, 'stripe_subscription_id' => 'sub_paying', ...$subscription]); Project::factory()->create(['team_id' => $team->id]); Livewire::test(ProfileIndex::class) ->assertSee('Delete all projects, servers, and Git sources of the team "Paying".', false) ->call('deleteAccount', 'password'); expect(User::find($user->id))->not->toBeNull() ->and(Team::find($team->id))->not->toBeNull(); })->with([ 'paid' => [['stripe_invoice_paid' => true]], 'past due' => [['stripe_past_due' => true]], ]); it('cancels the Stripe subscriptions of the email that Coolify does not record, except those of a team the user does not own', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $otherTeam = User::factory()->create()->teams()->first(); $otherTeam->members()->attach($user->id, ['role' => 'member']); fakeOpenStripeSubscriptions([ ['id' => 'sub_orphan', 'status' => 'active'], ['id' => 'sub_other_team', 'status' => 'active', 'metadata' => ['team_id' => (string) $otherTeam->id]], ]); $stripe = Mockery::mock(StripeClient::class); $stripe->subscriptions = Mockery::mock(SubscriptionService::class); $stripe->subscriptions->shouldReceive('retrieve')->with('sub_orphan')->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_orphan', 'status' => 'active'])); $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_orphan', []); $stripe->subscriptions->shouldNotReceive('cancel')->with('sub_other_team', []); app()->instance(StripeClient::class, $stripe); Livewire::test(ProfileIndex::class) ->assertSee('The Stripe subscription sub_orphan of your email, which is not linked to a team, will be cancelled immediately.') ->assertDontSee('sub_other_team') ->call('deleteAccount', 'password', []) ->assertRedirect(route('login')); expect(User::find($user->id))->toBeNull(); }); it('keeps the account when the Stripe lookup fails', function () { config(['constants.coolify.self_hosted' => false]); $user = deleteAccountUser(); $lookup = fakeOpenStripeSubscriptions(); $lookup->shouldReceive('handle')->andThrow(new RuntimeException('Stripe unavailable')); Livewire::test(ProfileIndex::class) ->assertSee('Every subscription of your teams and your email will be cancelled immediately.') ->call('deleteAccount', 'password', []); // The render after the failed delete reads the cached fallback; only handle() called Stripe again. $lookup->shouldHaveReceived('handle')->twice(); expect(User::find($user->id))->not->toBeNull(); });