From 166018706727aca57b8bc2997d64fefcab237b38 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 12:20:49 -0300 Subject: [PATCH] fix(profile): member delete must not destroy the shared account (closes #50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members joining via workspace invite share the owner's account_id. ProfileController::destroy was unconditionally calling $account->delete() in every profile-deletion path, so any member could wipe the whole organization (cascade: workspaces, posts, social accounts, signatures, labels) just by clicking Delete on their own profile. Gate the account/subscription teardown behind isAccountOwner(). For members the path now only detaches them from workspaces and deletes the user row — owner's data is untouched. Tested in both SELF_HOSTED=true and false. --- .../App/Settings/ProfileController.php | 17 +++-- tests/Feature/Settings/ProfileUpdateTest.php | 63 +++++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/App/Settings/ProfileController.php b/app/Http/Controllers/App/Settings/ProfileController.php index 0d7273be..880927ab 100644 --- a/app/Http/Controllers/App/Settings/ProfileController.php +++ b/app/Http/Controllers/App/Settings/ProfileController.php @@ -94,15 +94,7 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse $user->update(['current_workspace_id' => null]); $account = $user->account; - - // Cancel account subscription if exists - if ($account && $account->subscribed(Account::SUBSCRIPTION_NAME)) { - $account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow(); - } - - if ($account) { - $account->subscriptions()->delete(); - } + $isOwner = $user->isAccountOwner(); $ownedWorkspaces = Workspace::where('user_id', $user->id)->get(); @@ -126,7 +118,12 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse $user->workspaces()->detach(); - if ($account) { + if ($account && $isOwner) { + if ($account->subscribed(Account::SUBSCRIPTION_NAME)) { + $account->subscription(Account::SUBSCRIPTION_NAME)->cancelNow(); + } + + $account->subscriptions()->delete(); $account->delete(); } }); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 1955360a..60786517 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\UserWorkspace\Role; +use App\Models\Account; use App\Models\User; use App\Models\Workspace; use Illuminate\Http\UploadedFile; @@ -250,3 +251,65 @@ $response->assertRedirect(route('login')); }); + +test('member deleting profile does NOT destroy the shared account', function (bool $selfHosted) { + config()->set('trypost.self_hosted', $selfHosted); + + $owner = User::factory()->create(); + $member = User::factory()->create(['account_id' => $owner->account_id]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]); + $member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]); + + $this->actingAs($member)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect($member->fresh())->toBeNull(); + expect(Account::find($owner->account_id))->not->toBeNull(); + expect(Workspace::find($workspace->id))->not->toBeNull(); + expect($owner->fresh())->not->toBeNull(); +})->with([true, false]); + +test('member deleting profile detaches them from workspaces', function (bool $selfHosted) { + config()->set('trypost.self_hosted', $selfHosted); + + $owner = User::factory()->create(); + $member = User::factory()->create(['account_id' => $owner->account_id]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $owner->account_id, + 'user_id' => $owner->id, + ]); + $member->workspaces()->attach($workspace->id, ['role' => Role::Member->value]); + + $this->actingAs($member)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect($workspace->fresh()->members()->where('users.id', $member->id)->exists())->toBeFalse(); +})->with([true, false]); + +test('owner deleting profile destroys the account and cascades', function (bool $selfHosted) { + config()->set('trypost.self_hosted', $selfHosted); + + $owner = User::factory()->create(); + $accountId = $owner->account_id; + + $workspace = Workspace::factory()->create([ + 'account_id' => $accountId, + 'user_id' => $owner->id, + ]); + $owner->workspaces()->attach($workspace->id, ['role' => Role::Member->value]); + + $this->actingAs($owner)->delete(route('app.profile.destroy'), [ + 'password' => 'password', + ]); + + expect(Account::find($accountId))->toBeNull(); + expect(Workspace::find($workspace->id))->toBeNull(); +})->with([true, false]);