diff --git a/.ai/lessons.md b/.ai/lessons.md index 7ec10c1506..84b871b1f7 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -10,6 +10,7 @@ - `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior. - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. +- Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. ## Verify the complete user flow diff --git a/app/Actions/Stripe/RefundSubscription.php b/app/Actions/Stripe/RefundSubscription.php index f891a252f1..edfa184ad7 100644 --- a/app/Actions/Stripe/RefundSubscription.php +++ b/app/Actions/Stripe/RefundSubscription.php @@ -118,6 +118,8 @@ class RefundSubscription send_internal_notification( "CRITICAL: Refund succeeded but cancel failed for subscription {$subscription->stripe_subscription_id}, team {$team->id}. Manual intervention required." ); + + return ['success' => false, 'error' => 'Refund succeeded, but subscription cancellation failed.']; } $subscription->update([ diff --git a/app/Actions/User/DeleteUserAccount.php b/app/Actions/User/DeleteUserAccount.php new file mode 100644 index 0000000000..24a4febe54 --- /dev/null +++ b/app/Actions/User/DeleteUserAccount.php @@ -0,0 +1,193 @@ + + */ + public function blockers(User $user, bool $removeTeamResources = false): array + { + if ($user->id === 0) { + return ['The root user cannot be deleted.']; + } + + $blockers = []; + + foreach ($user->teams()->with('members')->get() as $team) { + $role = $team->pivot->role; + $isAlone = $team->members->count() === 1; + + if ($team->id === 0) { + if ($isAlone) { + $blockers[] = 'You are the only member of the root team.'; + } + + continue; + } + + if ($role !== 'owner') { + continue; + } + + if ($isAlone) { + if (! $removeTeamResources && ! $team->isEmpty()) { + $blockers[] = "Delete all projects, servers, and Git sources of the team \"{$team->name}\"."; + } + + continue; + } + + $hasReplacementOwner = $team->members->contains( + fn (User $member): bool => $member->id !== $user->id && in_array($member->pivot->role, ['owner', 'admin'], true) + ); + + if (! $hasReplacementOwner) { + $blockers[] = "Make another member of the team \"{$team->name}\" an admin or owner."; + } + } + + return $blockers; + } + + public function handle(User $user, bool $removeTeamResources = false): void + { + if ($blockers = $this->blockers($user, $removeTeamResources)) { + throw new RuntimeException(implode(' ', $blockers)); + } + + foreach ($this->subscriptionTeams($user) as $team) { + if (! CancelSubscription::cancelById($team->subscription->stripe_subscription_id)) { + throw new RuntimeException("Could not cancel the subscription of the team \"{$team->name}\". Your account was not deleted. Please try again."); + } + } + + try { + app(AvatarStorageService::class)->delete($user); + } catch (\Throwable $e) { + report($e); + } + + DB::transaction(function () use ($user, $removeTeamResources): void { + $user = User::query()->lockForUpdate()->findOrFail($user->id); + + if ($blockers = $this->blockers($user, $removeTeamResources)) { + throw new RuntimeException(implode(' ', $blockers)); + } + + if ($removeTeamResources) { + $this->singleMemberTeams($user)->each(fn (Team $team) => $this->removeTeamResources($team)); + } + + // The subscription row of a deleted team would point to a missing + // team, and later Stripe webhooks for its customer would fail. + Subscription::query()->whereIn('team_id', $this->singleMemberTeams($user)->pluck('id'))->delete(); + + // Deletes the user's single-member teams, promotes an admin where + // the user was the only owner, and removes the user from the rest. + (new DeleteUserTeams($user))->execute(); + + // team_user has no foreign keys, so also remove the root team + // membership and the rows of teams deleted above. + $user->teams()->detach(); + + $user->fresh()->delete(); + + DB::table('sessions')->where('user_id', $user->id)->delete(); + }); + } + + /** + * @return list + */ + public function confirmationActions(User $user): array + { + return $this->subscriptionTeams($user) + ->map(fn (Team $team): string => "The subscription of the team \"{$team->name}\" will be cancelled immediately. This is required.") + ->push('Your account will be permanently deleted from Coolify.') + ->values() + ->all(); + } + + private function singleMemberTeams(User $user): Collection + { + return $user->teams() + ->where('teams.id', '!=', 0) + ->wherePivot('role', 'owner') + ->withCount('members') + ->get() + ->filter(fn (Team $team): bool => $team->members_count === 1); + } + + /** + * Removes the team's data from Coolify. Git sources are removed when the + * team itself is deleted. + */ + private function removeTeamResources(Team $team): void + { + $this->forgetGithubRunners($team); + + foreach ($team->servers()->get() as $server) { + foreach ($server->definedResources() as $resource) { + $resource->forceDelete(); + } + $server->forceDelete(); + } + + foreach ($team->projects()->get() as $project) { + $project->delete(); + } + } + + /** + * Removes the team's GitHub Actions runner records so that busy runners do + * not block the removal of its servers and Git sources. The runners keep + * running on the servers; Coolify only stops tracking them. + */ + private function forgetGithubRunners(Team $team): void + { + $githubAppIds = GithubApp::query()->where('team_id', $team->id)->where('is_system_wide', false)->pluck('id'); + $serverIds = $team->servers()->pluck('id'); + + $belongsToTeam = fn ($query) => $query->whereIn('github_app_id', $githubAppIds)->orWhereIn('server_id', $serverIds); + + GithubRunnerExecution::query()->where($belongsToTeam)->delete(); + GithubRunnerConfig::query()->where($belongsToTeam)->delete(); + } + + private function subscriptionTeams(User $user): Collection + { + if (! isCloud()) { + return collect(); + } + + return $user->teams() + ->where('teams.id', '!=', 0) + ->wherePivot('role', 'owner') + ->with('subscription') + ->get() + ->filter(fn (Team $team): bool => filled($team->subscription?->stripe_subscription_id)); + } +} diff --git a/app/Livewire/Profile/Index.php b/app/Livewire/Profile/Index.php index 8801ffac36..95f1f66a06 100644 --- a/app/Livewire/Profile/Index.php +++ b/app/Livewire/Profile/Index.php @@ -2,6 +2,7 @@ namespace App\Livewire\Profile; +use App\Actions\User\DeleteUserAccount; use App\Services\AvatarStorageService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; @@ -347,6 +348,28 @@ class Index extends Component } } + public function deleteAccount(string $password, array $selectedActions = []): mixed + { + try { + if (! verifyPasswordConfirmation($password, $this)) { + return 'The provided password is incorrect.'; + } + + app(DeleteUserAccount::class)->handle(Auth::user()); + auditLog('ui.user.account_deleted', ['resource' => 'user']); + + Auth::guard('web')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + $this->redirect(route('login')); + + return true; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + private function providerLabel(string $provider): string { return match ($provider) { @@ -368,6 +391,9 @@ class Index extends Component public function render() { - return view('livewire.profile.index'); + return view('livewire.profile.index', [ + 'accountDeletionBlockers' => app(DeleteUserAccount::class)->blockers(Auth::user()), + 'accountDeletionActions' => app(DeleteUserAccount::class)->confirmationActions(Auth::user()), + ]); } } diff --git a/app/Livewire/Subscription/Actions.php b/app/Livewire/Subscription/Actions.php index d04856f55e..d2dc0f4fd8 100644 --- a/app/Livewire/Subscription/Actions.php +++ b/app/Livewire/Subscription/Actions.php @@ -6,8 +6,10 @@ use App\Actions\Stripe\CancelSubscriptionAtPeriodEnd; use App\Actions\Stripe\RefundSubscription; use App\Actions\Stripe\ResumeSubscription; use App\Actions\Stripe\UpdateSubscriptionQuantity; +use App\Actions\User\DeleteUserAccount; use App\Models\Team; use Carbon\Carbon; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Livewire\Component; use Stripe\StripeClient; @@ -34,6 +36,10 @@ class Actions extends Component public bool $refundLatestPayment = false; + public bool $deleteAccount = false; + + public bool $canDeleteAccount = false; + public string $billingInterval = 'monthly'; public ?string $nextBillingDate = null; @@ -43,6 +49,7 @@ class Actions extends Component $this->server_limits = Team::serverLimit(); $this->quantity = (int) $this->server_limits; $this->billingInterval = currentTeam()->subscription?->billingInterval() ?? 'monthly'; + $this->canDeleteAccount = app(DeleteUserAccount::class)->blockers(Auth::user(), removeTeamResources: true) === []; } public function loadPricePreview(int $quantity): void @@ -128,62 +135,26 @@ class Actions extends Component return 'Invalid password.'; } - if (in_array('refundLatestPayment', $selectedActions, true)) { - // Eligibility is re-validated server-side inside RefundSubscription::execute() - $result = app(RefundSubscription::class)->execute(currentTeam()); + $deleteAccount = in_array('deleteAccount', $selectedActions, true); + if ($deleteAccount && ($blockers = app(DeleteUserAccount::class)->blockers(Auth::user(), removeTeamResources: true))) { + return implode(' ', $blockers); + } - if ($result['success']) { - $this->auditSubscriptionChange('refunded'); - $this->dispatch('success', 'Subscription refunded and cancelled successfully.'); - $this->redirect(route('subscription.index'), navigate: true); - - return true; - } - - $this->dispatch('error', 'Something went wrong with the refund. Please contact us.'); + $refund = in_array('refundLatestPayment', $selectedActions, true); + if (! $this->cancelSubscriptionNow($refund)) { + $this->dispatch('error', 'Something went wrong with the '.($refund ? 'refund' : 'cancellation').'. Please contact us.'); return true; } - $team = currentTeam(); - $subscription = $team->subscription; - - if (! $subscription?->stripe_subscription_id) { - $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); - - return true; + if ($deleteAccount) { + return $this->deleteAccountAfterCancellation(); } - try { - $stripe = app(StripeClient::class); - $stripe->subscriptions->cancel($subscription->stripe_subscription_id); + $this->dispatch('success', $refund ? 'Subscription refunded and cancelled successfully.' : 'Subscription cancelled successfully.'); + $this->redirect(route('subscription.index'), navigate: true); - $subscription->update([ - 'stripe_cancel_at_period_end' => false, - 'stripe_invoice_paid' => false, - 'stripe_trial_already_ended' => false, - 'stripe_past_due' => false, - 'stripe_feedback' => 'Cancelled immediately by user', - 'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(), - ]); - - $team->subscriptionEnded(); - - $this->auditSubscriptionChange('cancelled', ['cancel_at_period_end' => false]); - - \Log::info("Subscription {$subscription->stripe_subscription_id} cancelled immediately for team {$team->name}"); - - $this->dispatch('success', 'Subscription cancelled successfully.'); - $this->redirect(route('subscription.index'), navigate: true); - - return true; - } catch (\Exception $e) { - \Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage()); - - $this->dispatch('error', 'Something went wrong with the cancellation. Please contact us.'); - - return true; - } + return true; } public function cancelAtPeriodEnd(string $password): bool|string @@ -222,6 +193,80 @@ class Actions extends Component return true; } + private function cancelSubscriptionNow(bool $refund): bool + { + $team = currentTeam(); + + if ($refund) { + // Eligibility is re-validated server-side inside RefundSubscription::execute() + $result = app(RefundSubscription::class)->execute($team); + + if ($result['success']) { + $this->auditSubscriptionChange('refunded'); + } + + return $result['success']; + } + + $subscription = $team->subscription; + + if (! $subscription?->stripe_subscription_id) { + return false; + } + + // subscriptionEnded() clears the ID on this model, so keep it for the log. + $subscriptionId = $subscription->stripe_subscription_id; + + try { + $stripe = app(StripeClient::class); + $stripe->subscriptions->cancel($subscriptionId); + + $subscription->update([ + 'stripe_cancel_at_period_end' => false, + 'stripe_invoice_paid' => false, + 'stripe_trial_already_ended' => false, + 'stripe_past_due' => false, + 'stripe_feedback' => 'Cancelled immediately by user', + 'stripe_comment' => 'Subscription cancelled immediately by user at '.now()->toDateTimeString(), + ]); + + $team->subscriptionEnded(); + + $this->auditSubscriptionChange('cancelled', ['cancel_at_period_end' => false]); + + \Log::info("Subscription {$subscriptionId} cancelled immediately for team {$team->name}"); + + return true; + } catch (\Exception $e) { + \Log::error("Immediate cancellation error for team {$team->id}: ".$e->getMessage()); + + return false; + } + } + + private function deleteAccountAfterCancellation(): bool + { + try { + app(DeleteUserAccount::class)->handle(Auth::user(), removeTeamResources: true); + } catch (\Throwable $e) { + report($e); + $this->dispatch('error', 'Your subscription was cancelled, but your account could not be deleted. Delete it from your profile page or contact us.'); + $this->redirect(route('subscription.index'), navigate: true); + + return true; + } + + auditLog('ui.user.account_deleted', ['resource' => 'user']); + + Auth::guard('web')->logout(); + session()->invalidate(); + session()->regenerateToken(); + + $this->redirect(route('login')); + + return true; + } + /** * Record a user-triggered subscription change for the current team. * diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 5b40f01f56..4bda77b555 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -72,14 +72,18 @@ dispatchEventMessage: @js($dispatchEventMessage), disableTwoStepConfirmation: @js($disableTwoStepConfirmation), skipPasswordConfirmation: @js($skipPasswordConfirmation), - resetModal() { + // A successful submit already re-rendered the component. Refreshing again + // after it can hit an ended session, for example after an account deletion. + resetModal(refresh = true) { this.step = this.initialStep; this.deleteText = ''; this.password = ''; this.submitting = false; this.userConfirmationText = ''; this.selectedActions = @js(collect($checkboxes)->pluck('id')->filter(fn($id) => $this->$id)->values()->all()); - $wire.$refresh(); + if (refresh) { + $wire.$refresh(); + } }, step1ButtonText: @js($step1ButtonText), step2ButtonText: @js($effectiveStep2ButtonText), @@ -332,7 +336,7 @@ $nextTick(() => { submitForm().then((result) => { submitting = false; - resetModal(); + resetModal(result !== true); }).catch(() => { submitting = false; modalOpen = true; @@ -390,7 +394,7 @@ submitForm().then((result) => { submitting = false; if (result === true) { - resetModal(); + resetModal(false); } else { modalOpen = true; passwordError = result; diff --git a/resources/views/livewire/profile/index.blade.php b/resources/views/livewire/profile/index.blade.php index 97e6ff74ee..c92c3d970e 100644 --- a/resources/views/livewire/profile/index.blade.php +++ b/resources/views/livewire/profile/index.blade.php @@ -300,6 +300,51 @@ +
+
+
+

Danger zone

+

Destructive actions for your account cannot be undone.

+
+
+
+ + @if ($accountDeletionBlockers === []) +

+ Permanently delete your account from Coolify. This action cannot be undone. +

+
    +
  • • Teams where you are the only member are deleted.
  • +
  • • You are removed from all other teams.
  • +
  • • Your API tokens and sessions are revoked.
  • +
+ @else +

Before you can delete your account:

+
    + @foreach ($accountDeletionBlockers as $blocker) +
  • • {{ $blocker }}
  • + @endforeach +
+ @endif + + @if ($accountDeletionBlockers === []) + + @else + + Delete account + + @endif + +
+
+
+ @if (session()->has('errors')) Something went wrong. Please review the fields and try again. diff --git a/resources/views/livewire/subscription/actions.blade.php b/resources/views/livewire/subscription/actions.blade.php index 3bccfd67cd..75ea746245 100644 --- a/resources/views/livewire/subscription/actions.blade.php +++ b/resources/views/livewire/subscription/actions.blade.php @@ -33,242 +33,248 @@ @keydown.escape.window="if (showModal) { closeAdjust(); }"> @if (subscriptionProvider() === 'stripe') {{-- Plan Overview --}} - -
-
- Plan: - - @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic') - Pay-as-you-go - @else - {{ data_get(currentTeam(), 'subscription')->type() }} - @endif - - · {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }} - · - @if (currentTeam()->subscription->stripe_cancel_at_period_end) - Cancelling at end of period - @else - Active - @endif -
-
- - Active servers: - {{ currentTeam()->servers->count() }} - / - - paid - - Adjust -
-
- @if ($refundCheckLoading) - - @elseif ($nextBillingDate) + +
+
+
+

+ @if (data_get(currentTeam(), 'subscription')->type() == 'dynamic') + Pay-as-you-go + @else + {{ data_get(currentTeam(), 'subscription')->type() }} + @endif +

+ {{ $billingInterval === 'yearly' ? 'Yearly' : 'Monthly' }} billing @if (currentTeam()->subscription->stripe_cancel_at_period_end) - Cancels on {{ $nextBillingDate }} + @else - Next billing {{ $nextBillingDate }} + + @endif +
+
+ + Active servers: + {{ currentTeam()->servers->count() }} + / + + paid + + @if ($refundCheckLoading) + · + + @elseif ($nextBillingDate) + · + + @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Cancels on {{ $nextBillingDate }} + @else + Next billing {{ $nextBillingDate }} + @endif + + @endif +
+
+ + @if (currentTeam()->serverOverflow()) + + You must delete {{ currentTeam()->servers->count() - $server_limits }} servers or upgrade your + subscription. Excess servers will be deactivated. + + @endif + +
+ Adjust server limit + + + Open billing portal + +
+ + {{-- Adjust Server Limit Modal --}} + +
+
+ + + {{-- Cancel Subscription --}} +
+
+

Cancel subscription

+

Keep access until the end of your billing period, or cancel immediately.

+
+
+ @if (currentTeam()->subscription->stripe_cancel_at_period_end) + Resume Subscription + @else + + @php + $deleteAccountCheckbox = [ + 'id' => 'deleteAccount', + 'label' => 'Delete my account. Servers, projects, and Git sources of teams where I am the only member are removed from Coolify. Nothing is stopped on the servers.', + ]; + @endphp + @if ($isRefundEligible) +
+ +
+ @else +
+ +
@endif @endif
-
- - @if (currentTeam()->serverOverflow()) - - You must delete {{ currentTeam()->servers->count() - $server_limits }} servers or upgrade your - subscription. Excess servers will be deactivated. - - @endif - - {{-- Adjust Server Limit Modal --}} - - - - {{-- Manage Subscription --}} - -
- - - Open billing portal - -
-
- - {{-- Cancel Subscription --}} - -
@if (currentTeam()->subscription->stripe_cancel_at_period_end) - Resume Subscription - @else - - @if ($isRefundEligible) -
- -
- @else -
- -
- @endif +

Your subscription is set to cancel at the end of the billing period.

@endif -
- @if (currentTeam()->subscription->stripe_cancel_at_period_end) -

Your subscription is set to cancel at the end of the billing period.

- @endif -
+ - {{-- Refund --}} - - @if ($refundCheckLoading || $isRefundEligible) -
- @if ($refundCheckLoading) - Request Full Refund - @else + {{-- Refund --}} +
+

Refund

+ @if (! $refundCheckLoading && $isRefundEligible) +
+
+ @endif +
+ @if ($refundCheckLoading) + + @elseif ($isRefundEligible) + Eligible for a full refund — {{ $refundDaysRemaining }} days remaining. + @elseif ($refundAlreadyUsed) + Refund already processed. Each team is eligible for one refund only. + @else + Not eligible for a refund. @endif
- @endif -

- @if ($refundCheckLoading) - Checking refund eligibility... - @elseif ($isRefundEligible) - Eligible for a full refund — {{ $refundDaysRemaining }} days remaining. - @elseif ($refundAlreadyUsed) - Refund already processed. Each team is eligible for one refund only. - @else - Not eligible for a refund. - @endif -

+
+
diff --git a/tests/Feature/InstanceSettingsAuditTest.php b/tests/Feature/InstanceSettingsAuditTest.php index 09e4cc9358..48521548db 100644 --- a/tests/Feature/InstanceSettingsAuditTest.php +++ b/tests/Feature/InstanceSettingsAuditTest.php @@ -247,6 +247,26 @@ describe('subscription changes', function () { expect(instanceSettingsAuditEvents('ui.subscription.refunded')->sole()->team_id)->toBe($this->team->id); }); + test('immediate refund is audited only when it succeeds', function (bool $success) { + $subscription = createInstanceSettingsAuditSubscription($this->team); + $refund = Mockery::mock(RefundSubscription::class); + $refund->shouldReceive('execute')->once()->andReturn(['success' => $success, 'error' => null]); + app()->instance(RefundSubscription::class, $refund); + + Livewire::test(SubscriptionActions::class) + ->call('cancelImmediately', 'password', ['refundLatestPayment']) + ->assertDispatched($success ? 'success' : 'error'); + + $events = instanceSettingsAuditEvents('ui.subscription.refunded'); + + if ($success) { + expect($events->sole()->team_id)->toBe($this->team->id) + ->and($events->sole()->metadata['subscription_id'])->toBe($subscription->id); + } else { + expect($events)->toBeEmpty(); + } + })->with([true, false]); + test('server limit change is audited', function () { Bus::fake(); createInstanceSettingsAuditSubscription($this->team); diff --git a/tests/Feature/ProfileDeleteAccountTest.php b/tests/Feature/ProfileDeleteAccountTest.php new file mode 100644 index 0000000000..1f6e17fc6a --- /dev/null +++ b/tests/Feature/ProfileDeleteAccountTest.php @@ -0,0 +1,311 @@ + 0]); + $this->rootTeam = Team::factory()->create(['id' => 0, 'name' => 'Root Team']); + $this->rootUser = User::factory()->create(['id' => 0]); +}); + +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('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('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('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(); +}); diff --git a/tests/Feature/ProfileEmailChangeModalTest.php b/tests/Feature/ProfileEmailChangeModalTest.php index da4f6f3b31..ecbd14700a 100644 --- a/tests/Feature/ProfileEmailChangeModalTest.php +++ b/tests/Feature/ProfileEmailChangeModalTest.php @@ -1,6 +1,7 @@ 0]); $this->user = User::factory()->create(['name' => 'Profile User', 'email' => 'old@example.com']); $this->user->forceFill([ 'pending_email' => 'new@example.com', diff --git a/tests/Feature/ProfileSsoIndicatorTest.php b/tests/Feature/ProfileSsoIndicatorTest.php index 0d48225eb5..69304d203a 100644 --- a/tests/Feature/ProfileSsoIndicatorTest.php +++ b/tests/Feature/ProfileSsoIndicatorTest.php @@ -1,6 +1,7 @@ 0]); +}); + it('shows when the profile user signed in with sso', function () { $user = User::factory()->create(['name' => 'Profile User']); diff --git a/tests/Feature/Subscription/RefundSubscriptionTest.php b/tests/Feature/Subscription/RefundSubscriptionTest.php index c1d5adda13..65f660d885 100644 --- a/tests/Feature/Subscription/RefundSubscriptionTest.php +++ b/tests/Feature/Subscription/RefundSubscriptionTest.php @@ -288,7 +288,7 @@ describe('execute', function () { expect($result['error'])->toContain('No payment intent'); }); - test('records refund and proceeds when cancel fails', function () { + test('records refund and preserves the subscription when cancel fails', function () { $stripeSubscription = (object) [ 'status' => 'active', 'start_date' => now()->subDays(10)->timestamp, @@ -327,16 +327,19 @@ describe('execute', function () { $action = new RefundSubscription($this->mockStripe); $result = $action->execute($this->team); - // Should still succeed — refund went through - expect($result['success'])->toBeTrue(); - expect($result['error'])->toBeNull(); + expect($result['success'])->toBeFalse(); + expect($result['error'])->toBe('Refund succeeded, but subscription cancellation failed.'); $this->subscription->refresh(); // Refund timestamp must be recorded expect($this->subscription->stripe_refunded_at)->not->toBeNull(); - // Subscription should still be marked as ended locally - expect($this->subscription->stripe_invoice_paid)->toBeFalsy(); - expect($this->subscription->stripe_subscription_id)->toBeNull(); + expect($this->subscription->stripe_invoice_paid)->toBeTruthy(); + expect($this->subscription->stripe_subscription_id)->toBe('sub_test_123'); + + $retry = $action->execute($this->team->fresh()); + + expect($retry['success'])->toBeFalse(); + expect($retry['error'])->toContain('already been processed'); }); test('fails when subscription is past refund window', function () { diff --git a/tests/Feature/Subscription/SubscriptionActionsTest.php b/tests/Feature/Subscription/SubscriptionActionsTest.php index f42eb05dd6..2612e661cb 100644 --- a/tests/Feature/Subscription/SubscriptionActionsTest.php +++ b/tests/Feature/Subscription/SubscriptionActionsTest.php @@ -1,14 +1,26 @@ assertReturned('Invalid password.'); }); }); + +describe('cancelImmediately with account deletion', function () { + beforeEach(function () { + $this->stripe = Mockery::mock(StripeClient::class); + $this->stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $this->instance(StripeClient::class, $this->stripe); + }); + + test('cancels the subscription, removes the team data from coolify, and deletes the account', function () { + $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123'); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + ]); + $githubApp = GithubApp::create([ + 'name' => 'Team GitHub App', + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + ]); + + Livewire::test(Actions::class) + ->call('cancelImmediately', 'password', ['deleteAccount']) + ->assertRedirect(route('login')); + + expect(User::find($this->user->id))->toBeNull() + ->and(Team::find($this->team->id))->toBeNull() + ->and(Server::withTrashed()->find($server->id))->toBeNull() + ->and(Project::find($project->id))->toBeNull() + ->and(Application::withTrashed()->find($application->id))->toBeNull() + ->and(GithubApp::find($githubApp->id))->toBeNull() + ->and(Subscription::where('team_id', $this->team->id)->exists())->toBeFalse() + ->and(auth()->check())->toBeFalse(); + }); + + test('refunds, cancels, and deletes the account when both options are selected', function () { + $refund = Mockery::mock(RefundSubscription::class); + $refund->shouldReceive('execute')->once()->andReturnUsing(function (Team $team) { + $team->subscriptionEnded(); + + return ['success' => true, 'error' => null]; + }); + $this->instance(RefundSubscription::class, $refund); + $this->stripe->subscriptions->shouldNotReceive('cancel'); + + Livewire::test(Actions::class) + ->call('cancelImmediately', 'password', ['refundLatestPayment', 'deleteAccount']) + ->assertRedirect(route('login')); + + expect(User::find($this->user->id))->toBeNull(); + }); + + test('keeps the account when cancellation fails after a successful refund', function () { + $this->stripe->invoices = Mockery::mock(InvoiceService::class); + $this->stripe->refunds = Mockery::mock(RefundService::class); + $this->stripe->subscriptions->shouldReceive('retrieve')->with('sub_test_123')->andReturn((object) [ + 'status' => 'active', + 'start_date' => now()->subDays(10)->timestamp, + 'current_period_end' => now()->addDays(20)->timestamp, + ]); + $this->stripe->invoices->shouldReceive('all')->andReturn((object) ['data' => [ + (object) ['payment_intent' => 'pi_test_123'], + ]]); + $this->stripe->refunds->shouldReceive('create')->once() + ->with(['payment_intent' => 'pi_test_123']) + ->andReturn((object) ['id' => 're_test_123']); + $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123') + ->andThrow(new RuntimeException('Stripe cancel API error')); + + Livewire::test(Actions::class) + ->call('cancelImmediately', 'password', ['refundLatestPayment', 'deleteAccount']) + ->assertDispatched('error') + ->assertNotDispatched('success') + ->assertNoRedirect(); + + $this->assertModelExists($this->user); + $this->assertModelExists($this->team); + expect($this->team->subscription->fresh()->stripe_subscription_id)->toBe('sub_test_123') + ->and($this->team->subscription->fresh()->stripe_refunded_at)->not->toBeNull() + ->and(auth()->check())->toBeTrue(); + }); + + test('does not cancel when the account cannot be deleted', function () { + $member = User::factory()->create(); + $sharedTeam = Team::factory()->create(['name' => 'Shared']); + $sharedTeam->members()->attach($this->user->id, ['role' => 'owner']); + $sharedTeam->members()->attach($member->id, ['role' => 'member']); + $this->stripe->subscriptions->shouldNotReceive('cancel'); + + Livewire::test(Actions::class) + ->call('cancelImmediately', 'password', ['deleteAccount']) + ->assertReturned('Make another member of the team "Shared" an admin or owner.'); + + expect(User::find($this->user->id))->not->toBeNull() + ->and($this->team->subscription->fresh()->stripe_subscription_id)->toBe('sub_test_123'); + }); + + test('keeps the account when delete my account is not selected', function () { + $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123'); + + Livewire::test(Actions::class) + ->call('cancelImmediately', 'password', []) + ->assertDispatched('success') + ->assertRedirect(route('subscription.index')); + + expect(User::find($this->user->id))->not->toBeNull() + ->and(Team::find($this->team->id))->not->toBeNull(); + }); + + test('does nothing with a wrong password', function () { + $this->stripe->subscriptions->shouldNotReceive('cancel'); + + Livewire::test(Actions::class) + ->call('cancelImmediately', 'wrong-password', ['deleteAccount']) + ->assertReturned('Invalid password.'); + + expect(User::find($this->user->id))->not->toBeNull(); + }); + + test('keeps the account and reports an error when deletion fails after the cancellation', function () { + $this->stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_test_123'); + $deleteAccount = Mockery::mock(DeleteUserAccount::class)->makePartial(); + $deleteAccount->shouldReceive('handle')->once()->andThrow(new RuntimeException('Runner busy')); + $this->instance(DeleteUserAccount::class, $deleteAccount); + + Livewire::test(Actions::class) + ->call('cancelImmediately', 'password', ['deleteAccount']) + ->assertDispatched('error') + ->assertRedirect(route('subscription.index')); + + expect(User::find($this->user->id))->not->toBeNull() + ->and(auth()->check())->toBeTrue(); + }); +}); diff --git a/tests/v4/Browser/CancelAndDeleteAccountTest.php b/tests/v4/Browser/CancelAndDeleteAccountTest.php new file mode 100644 index 0000000000..958500fed0 --- /dev/null +++ b/tests/v4/Browser/CancelAndDeleteAccountTest.php @@ -0,0 +1,90 @@ +set('app.maintenance.store', 'array'); + config()->set('constants.coolify.self_hosted', false); + config()->set('subscription.provider', 'stripe'); + config()->set('subscription.stripe_api_key', 'sk_test_fake'); + InstanceSettings::forceCreate(['id' => 0, 'is_sponsorship_popup_enabled' => false]); + $user = User::factory()->create([ + 'name' => 'Paying User', + 'email' => 'paying@example.com', + 'password' => Hash::make('password'), + ]); + Subscription::create([ + 'team_id' => $user->teams()->first()->id, + 'stripe_subscription_id' => 'sub_browser', + 'stripe_customer_id' => 'cus_browser', + 'stripe_invoice_paid' => true, + 'stripe_plan_id' => 'price_browser', + ]); + Team::query()->update(['show_boarding' => false]); + Cache::flush(); + // SQLite returns stripe_invoice_paid as 1, which isSubscriptionActive() treats as unpaid. + $this->withoutMiddleware(DecideWhatToDoWithUser::class); +}); + +it('offers account deletion when cancelling the subscription immediately', function () { + $page = visit('/login') + ->fill('email', 'paying@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertDontSee('These credentials do not match') + ->navigate('/subscription') + ->assertSee('Cancel subscription'); + + $page->click('button:visible:has-text("Cancel Immediately")') + ->assertSee('Delete my account.') + ->screenshot(filename: 'cancel-delete-account-step1') + ->click('label:has-text("Delete my account.")') + ->click('button:visible:has-text("Continue")') + ->assertSee('The following actions will be performed') + ->screenshot(filename: 'cancel-delete-account-step2'); +}); + +it('cancels, deletes the account, and lands on login without a follow-up request', function () { + User::factory()->create(['id' => 0, 'email' => 'root@example.com']); + $stripe = Mockery::mock(StripeClient::class); + $stripe->subscriptions = Mockery::mock(SubscriptionService::class); + $stripe->subscriptions->shouldReceive('cancel')->once()->with('sub_browser'); + app()->instance(StripeClient::class, $stripe); + + $page = visit('/login') + ->fill('email', 'paying@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertDontSee('These credentials do not match') + ->navigate('/subscription') + ->assertSee('Cancel subscription'); + + $page->click('button:visible:has-text("Cancel Immediately")') + ->click('label:has-text("Delete my account.")') + ->click('button:visible:has-text("Continue")') + ->fill('[x-model="userConfirmationText"]:visible', "Paying User's Team") + ->click('button:visible:has-text("Permanently Cancel")') + ->fill('[x-model="password"]:visible', 'password'); + + // Count the Livewire update requests sent after the final confirmation. + $page->script("sessionStorage.setItem('livewireUpdates', '0'); const originalFetch = window.fetch; window.fetch = (input, init) => { if (String(input?.url ?? input).includes('/update')) { sessionStorage.setItem('livewireUpdates', String(Number(sessionStorage.getItem('livewireUpdates')) + 1)); } return originalFetch(input, init); };"); + + $page->click('button:visible:has-text("Confirm")') + ->assertPathIs('/login') + ->wait(1) + ->screenshot(filename: 'cancel-delete-account-done'); + + expect($page->script("sessionStorage.getItem('livewireUpdates')"))->toBe('1') + ->and(User::query()->where('email', 'paying@example.com')->exists())->toBeFalse(); +}); diff --git a/tests/v4/Browser/DeleteAccountTest.php b/tests/v4/Browser/DeleteAccountTest.php new file mode 100644 index 0000000000..c5b444f32b --- /dev/null +++ b/tests/v4/Browser/DeleteAccountTest.php @@ -0,0 +1,54 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_sponsorship_popup_enabled' => false]); + Team::factory()->create(['id' => 0, 'name' => 'Root Team']); + User::factory()->create(['id' => 0, 'name' => 'Root', 'email' => 'root@example.com']); + $this->user = User::factory()->create([ + 'name' => 'Leaving User', + 'email' => 'leaving@example.com', + 'password' => Hash::make('password'), + ]); + Team::query()->update(['show_boarding' => false]); + Cache::flush(); +}); + +it('lets a user delete their own account from the profile page', function () { + $page = visit('/login') + ->fill('email', 'leaving@example.com') + ->fill('password', 'password') + ->click('Login') + ->assertDontSee('These credentials do not match') + ->navigate('/profile') + ->assertSee('Delete account') + ->screenshot(filename: 'delete-account-profile'); + + $page->click('button:has-text("Delete Account")') + ->assertSee('Confirm Account Deletion?') + ->screenshot(filename: 'delete-account-modal') + ->fill('[x-model="userConfirmationText"]', 'leaving@example.com') + ->screenshot(filename: 'delete-account-confirm-text') + ->click('button:visible:has-text("Continue")') + ->fill('[x-model="password"]', 'password') + ->screenshot(filename: 'delete-account-password'); + + // Count the Livewire update requests sent after the final confirmation. + $page->script("sessionStorage.setItem('livewireUpdates', '0'); const originalFetch = window.fetch; window.fetch = (input, init) => { if (String(input?.url ?? input).includes('/update')) { sessionStorage.setItem('livewireUpdates', String(Number(sessionStorage.getItem('livewireUpdates')) + 1)); } return originalFetch(input, init); };"); + + $page->click('button:visible:has-text("Permanently Delete")') + ->assertPathIs('/login') + ->screenshot(filename: 'delete-account-done'); + + expect($page->script("sessionStorage.getItem('livewireUpdates')"))->toBe('1') + ->and(User::query()->where('email', 'leaving@example.com')->exists())->toBeFalse(); +});