Scope reclaim-username profile deletion and fail on surviving orphan

pull/7122/head
Your Name 3 weeks ago
parent c40814d9b3
commit a1724a4b1c

@ -37,10 +37,9 @@ class ReclaimUsername extends Command
);
$user = User::whereUsername($username)->withTrashed()->first();
$profile = Profile::whereUsername($username)->withTrashed()->first();
if (! $user && ! $profile) {
$this->error("No user or profile found with username: {$username}");
if (! $user) {
$this->error("No user found with username: {$username}");
return Command::FAILURE;
}
@ -62,14 +61,29 @@ class ReclaimUsername extends Command
return Command::SUCCESS;
}
if ($user) {
$user->forceDelete();
$this->info("User {$username} has been force deleted.");
}
// Force-delete the user's OWN profiles, scoped by user_id rather than a
// bare username match, so we never destroy a different user's profile
// that happens to share the username.
Profile::whereUserId($user->id)
->withTrashed()
->get()
->each
->forceDelete();
$this->info("Profile {$username} has been force deleted.");
$user->forceDelete();
$this->info("User {$username} has been force deleted.");
if ($profile) {
$profile->forceDelete();
$this->info("Profile {$username} has been force deleted.");
// A same-username orphan profile (not linked to this user) can survive
// and continue to claim the username. Verify the username is genuinely
// free before reporting success.
$survivors = Profile::whereUsername($username)->withTrashed()->get();
if ($survivors->isNotEmpty()) {
$this->warn("Found {$survivors->count()} surviving profile(s) with username '{$username}' (IDs: {$survivors->pluck('id')->implode(', ')}).");
$this->error('Username could not be fully reclaimed. Manual cleanup of surviving profiles is required.');
return Command::FAILURE;
}
$this->info('Username reclaimed successfully!');

@ -0,0 +1,87 @@
<?php
use App\Models\Profile;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| app:reclaim-username
|--------------------------------------------------------------------------
|
| Reclaiming a deleted user's username must force-delete the user's OWN
| profile(s) (scoped by user_id) and must not report success while a
| same-username orphan profile still claims the username.
|
*/
/**
* Mark a user as deleted so the reclaim gate (status/delete_after) passes.
*/
function markDeleted(User $user): void
{
$user->status = 'deleted';
$user->delete_after = now()->subDay();
$user->save();
}
it('force-deletes only the user\'s own profile, not a same-username orphan, and fails when the orphan survives', function () {
$target = User::factory()->create(['username' => 'reclaimme']);
$target->refresh();
$ownProfileId = $target->profile_id;
markDeleted($target);
// A live orphan profile with the same username but NOT linked to $target.
// (domain,username) unique permits this because domain is NULL.
$orphan = Profile::factory()->create([
'username' => 'reclaimme',
'user_id' => null,
]);
$this->artisan('app:reclaim-username')
->expectsSearch(
'What username would you like to reclaim?',
answer: 'reclaimme',
search: 'reclaimme',
answers: ['reclaimme'],
)
->expectsConfirmation(
'Are you sure you want to force delete user and profile with username: reclaimme?',
'yes'
)
->assertExitCode(1);
// The orphan must survive: we only delete the user's own profile.
expect(Profile::whereId($orphan->id)->exists())->toBeTrue();
// The user's own profile is gone (force-deleted, scoped by user_id).
expect(Profile::whereId($ownProfileId)->withTrashed()->exists())->toBeFalse();
});
it('reclaims cleanly and reports success when no orphan survives', function () {
$target = User::factory()->create(['username' => 'soloname']);
$target->refresh();
$ownProfileId = $target->profile_id;
markDeleted($target);
$this->artisan('app:reclaim-username')
->expectsSearch(
'What username would you like to reclaim?',
answer: 'soloname',
search: 'soloname',
answers: ['soloname'],
)
->expectsConfirmation(
'Are you sure you want to force delete user and profile with username: soloname?',
'yes'
)
->expectsOutputToContain('Username reclaimed successfully!')
->assertExitCode(0);
expect(Profile::whereUsername('soloname')->withTrashed()->exists())->toBeFalse();
expect(User::whereId($target->id)->withTrashed()->exists())->toBeFalse();
expect($ownProfileId)->not->toBeNull();
});
Loading…
Cancel
Save