mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
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:
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user