fix(teams): make membership and source deletions atomic

Wrap team membership changes, invitation revocation, and GitHub source deletion in transactions, with coverage for rollback failures.
This commit is contained in:
Andras Bacsai
2026-08-20 09:38:50 +02:00
parent 1eea1a1412
commit 8f90882875
7 changed files with 140 additions and 13 deletions
@@ -6,9 +6,11 @@ use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -96,6 +98,44 @@ test('role downgrade through team member component revokes team tokens', functio
expect(DB::table('personal_access_tokens')->where('id', $token->id)->exists())->toBeFalse();
});
test('member removal rolls back when token revocation fails', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$token = $this->user->createToken('protected-token', ['read'])->accessToken;
Schema::create('protected_personal_access_tokens', function (Blueprint $table): void {
$table->foreignId('token_id')->constrained('personal_access_tokens');
});
DB::table('protected_personal_access_tokens')->insert(['token_id' => $token->id]);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call('remove')
->assertDispatched('error');
expect($this->team->members()->whereKey($this->user->id)->exists())->toBeTrue();
});
test('role change rolls back when token revocation fails', function () {
$owner = User::factory()->create();
$this->team->members()->attach($owner->id, ['role' => 'owner']);
$token = $this->user->createToken('protected-token', ['write'])->accessToken;
Schema::create('protected_personal_access_tokens', function (Blueprint $table): void {
$table->foreignId('token_id')->constrained('personal_access_tokens');
});
DB::table('protected_personal_access_tokens')->insert(['token_id' => $token->id]);
$this->actingAs($owner);
session(['currentTeam' => $this->team]);
Livewire::test(Member::class, ['member' => $this->user])
->call('makeReadonly')
->assertDispatched('error');
expect($this->user->fresh()->teams()->findOrFail($this->team->id)->pivot->role)->toBe('admin');
});
test('member cannot create write token through livewire token form', function () {
$this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
@@ -0,0 +1,39 @@
<?php
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function sourcePrivateKey(Team $team): PrivateKey
{
return PrivateKey::factory()->create([
'team_id' => $team->id,
'is_git_related' => true,
]);
}
test('github source deletion rolls back unused private key deletion when source deletion fails', function () {
$team = Team::factory()->create();
$privateKey = sourcePrivateKey($team);
$githubApp = GithubApp::create([
'name' => 'GitHub',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'private_key_id' => $privateKey->id,
'team_id' => $team->id,
]);
GithubApp::deleting(function (GithubApp $deletingApp) use ($githubApp): void {
if ($deletingApp->is($githubApp)) {
throw new RuntimeException('Source deletion failed.');
}
});
expect(fn () => $githubApp->delete())->toThrow(RuntimeException::class, 'Source deletion failed.');
$this->assertModelExists($githubApp);
$this->assertModelExists($privateKey);
});
+29
View File
@@ -73,3 +73,32 @@ it('exposes a resilient global copyToClipboard helper', function () {
->toContain('document.execCommand(\'copy\')')
->toContain('window.isSecureContext');
});
it('preserves a provisional user when revoking their invitation fails', function () {
$provisionalUser = User::factory()->create([
'email' => 'provisional@example.com',
'email_verified_at' => null,
'force_password_reset' => true,
]);
$invitation = TeamInvitation::create([
'team_id' => $this->team->id,
'uuid' => 'failing-invitation-delete',
'email' => $provisionalUser->email,
'role' => 'member',
'link' => 'http://example.test/invitations/failing-invitation-delete',
'via' => 'link',
]);
TeamInvitation::deleting(function (): void {
throw new RuntimeException('Invitation deletion failed.');
});
Livewire::test(Invitations::class, [
'invitations' => collect([$invitation]),
])
->call('deleteInvitation', $invitation->id)
->assertDispatched('error');
$this->assertDatabaseHas('users', ['id' => $provisionalUser->id]);
$this->assertDatabaseHas('team_invitations', ['id' => $invitation->id]);
});