mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Furox <177975472+Furox-Art@users.noreply.github.com> Co-authored-by: johandenhollander <johan.den.hollander@finalist.nl> Co-authored-by: João Cruz <161638790+SrJooJ@users.noreply.github.com> Co-authored-by: Saim Hashmi <hashmisaim037@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
214 lines
8.6 KiB
PHP
214 lines
8.6 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Profile\Index;
|
|
use App\Models\InstanceSettings;
|
|
use App\Models\S3Storage;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\AvatarStorageService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
InstanceSettings::unguarded(fn () => InstanceSettings::create([
|
|
'id' => 0,
|
|
'avatar_storage_type' => 'local',
|
|
]));
|
|
});
|
|
|
|
it('compresses and stores an uploaded profile picture on the configured local storage', function () {
|
|
Storage::fake('images');
|
|
$user = User::factory()->create(['name' => 'Test User']);
|
|
$this->actingAs($user);
|
|
|
|
Livewire::test(Index::class)
|
|
->set('avatar', UploadedFile::fake()->image('profile.png', 1200, 900))
|
|
->call('uploadAvatar')
|
|
->assertHasNoErrors();
|
|
|
|
$user->refresh();
|
|
|
|
expect($user->avatar_path)
|
|
->toBe("avatars/{$user->id}/avatar.jpg")
|
|
->and($user->avatar_storage_type)->toBe('local')
|
|
->and($user->avatar_s3_storage_id)->toBeNull();
|
|
|
|
Storage::disk('images')->assertExists($user->avatar_path);
|
|
|
|
$image = getimagesizefromstring(Storage::disk('images')->get($user->avatar_path));
|
|
|
|
expect($image[0])->toBeLessThanOrEqual(256)
|
|
->and($image[1])->toBeLessThanOrEqual(256)
|
|
->and($image['mime'])->toBe('image/jpeg')
|
|
->and(Storage::disk('images')->size($user->avatar_path))->toBeLessThan(100_000);
|
|
})->skip(! extension_loaded('gd'), 'Requires the GD extension to generate and compress the test image.');
|
|
|
|
it('stores an already compressed browser JPEG without server image extensions', function () {
|
|
Storage::fake('images');
|
|
$user = User::factory()->create(['name' => 'Test User']);
|
|
$contents = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gODAK/9sAQwAGBAUGBQQGBgUGBwcGCAoQCgoJCQoUDg8MEBcUGBgXFBYWGh0lHxobIxwWFiAsICMmJykqKRkfLTAtKDAlKCko/9sAQwEHBwcKCAoTCgoTKBoWGigoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo/8AAEQgAAgACAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+qaKKKAP/9k=');
|
|
$path = tempnam(sys_get_temp_dir(), 'avatar');
|
|
file_put_contents($path, $contents);
|
|
$upload = new UploadedFile($path, 'avatar.jpg', 'image/jpeg', null, true);
|
|
|
|
app(AvatarStorageService::class)->store($user, $upload);
|
|
|
|
expect(Storage::disk('images')->get("avatars/{$user->id}/avatar.jpg"))->toBe($contents);
|
|
});
|
|
|
|
it('serves the authenticated users profile picture', function () {
|
|
Storage::fake('images');
|
|
$user = User::factory()->create([
|
|
'name' => 'Test User',
|
|
'avatar_path' => 'avatars/1/avatar.jpg',
|
|
'avatar_storage_type' => 'local',
|
|
]);
|
|
Storage::disk('images')->put($user->avatar_path, 'avatar-content');
|
|
|
|
$this->withoutMiddleware()->actingAs($user)
|
|
->get(route('profile.avatar'))
|
|
->assertSuccessful()
|
|
->assertHeader('content-type', 'image/jpeg')
|
|
->assertHeader('cache-control', 'immutable, max-age=31536000, private');
|
|
});
|
|
|
|
it('loads an S3 profile picture from the configured CDN', function () {
|
|
InstanceSettings::findOrFail(0)->update([
|
|
'image_cdn_url' => 'https://avatars.example.com/media',
|
|
]);
|
|
Team::factory()->create(['id' => 0]);
|
|
$storage = S3Storage::query()->create([
|
|
'team_id' => 0,
|
|
'name' => 'Avatar storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'avatars',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'is_usable' => true,
|
|
]);
|
|
$user = User::factory()->create([
|
|
'avatar_path' => 'avatars/1/avatar.jpg',
|
|
'avatar_storage_type' => 's3',
|
|
'avatar_s3_storage_id' => $storage->id,
|
|
]);
|
|
|
|
expect(profile_avatar_url($user))->toBe("https://avatars.example.com/media/avatars/1/avatar.jpg?v={$user->updated_at->timestamp}");
|
|
});
|
|
|
|
it('falls back to the authenticated proxy route for an S3 profile picture when the CDN is not configured', function () {
|
|
Team::factory()->create(['id' => 0]);
|
|
$storage = S3Storage::query()->create([
|
|
'team_id' => 0,
|
|
'name' => 'Avatar storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'avatars',
|
|
'endpoint' => 'https://s3.example.com',
|
|
'is_usable' => true,
|
|
]);
|
|
$user = User::factory()->create([
|
|
'avatar_path' => 'avatars/1/avatar.jpg',
|
|
'avatar_storage_type' => 's3',
|
|
'avatar_s3_storage_id' => $storage->id,
|
|
]);
|
|
|
|
// Direct S3-compatible endpoints (e.g. Cloudflare R2) require authenticated requests,
|
|
// so without a CDN the browser must use the app's authenticated proxy route.
|
|
expect(profile_avatar_url($user))->toBe(route('profile.avatar', ['v' => $user->updated_at->timestamp]));
|
|
});
|
|
|
|
it('does not use an unrelated S3 storage URL for a profile picture', function () {
|
|
$storage = S3Storage::query()->create([
|
|
'team_id' => Team::factory()->create()->id,
|
|
'name' => 'Unrelated storage',
|
|
'region' => 'us-east-1',
|
|
'key' => 'key',
|
|
'secret' => 'secret',
|
|
'bucket' => 'avatars',
|
|
'endpoint' => 'https://unrelated.example.com',
|
|
'is_usable' => true,
|
|
]);
|
|
$user = User::factory()->create([
|
|
'avatar_path' => 'avatars/1/avatar.jpg',
|
|
'avatar_storage_type' => 's3',
|
|
'avatar_s3_storage_id' => $storage->id,
|
|
]);
|
|
|
|
expect(profile_avatar_url($user))->toBe(route('profile.avatar', ['v' => $user->updated_at->timestamp]));
|
|
});
|
|
|
|
it('removes the current profile picture', function () {
|
|
Storage::fake('images');
|
|
$user = User::factory()->create([
|
|
'name' => 'Test User',
|
|
'avatar_path' => 'avatars/1/avatar.jpg',
|
|
'avatar_storage_type' => 'local',
|
|
]);
|
|
Storage::disk('images')->put($user->avatar_path, 'avatar-content');
|
|
$this->actingAs($user);
|
|
|
|
Livewire::test(Index::class)
|
|
->call('removeAvatar')
|
|
->assertHasNoErrors();
|
|
|
|
expect($user->refresh()->avatar_path)->toBeNull();
|
|
Storage::disk('images')->assertMissing('avatars/1/avatar.jpg');
|
|
});
|
|
|
|
it('falls back cleanly when the avatars S3 storage no longer exists', function () {
|
|
$user = User::factory()->create([
|
|
'name' => 'Test User',
|
|
'avatar_path' => 'avatars/1/avatar.webp',
|
|
'avatar_storage_type' => 's3',
|
|
'avatar_s3_storage_id' => null,
|
|
]);
|
|
|
|
$this->withoutMiddleware()->actingAs($user)
|
|
->get(route('profile.avatar'))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('serves a team members profile picture to other members of the same team', function () {
|
|
Storage::fake('images');
|
|
$team = Team::factory()->create();
|
|
$viewer = User::factory()->create();
|
|
$member = User::factory()->create([
|
|
'avatar_path' => 'avatars/2/avatar.jpg',
|
|
'avatar_storage_type' => 'local',
|
|
]);
|
|
$team->members()->attach($viewer->id, ['role' => 'member']);
|
|
$team->members()->attach($member->id, ['role' => 'member']);
|
|
Storage::disk('images')->put($member->avatar_path, 'avatar-content');
|
|
|
|
$this->actingAs($viewer);
|
|
|
|
expect(profile_avatar_url($member))->toBe(route('team.member.avatar', ['user_id' => $member->id, 'v' => $member->updated_at->timestamp]));
|
|
|
|
$this->withoutMiddleware()
|
|
->get(route('team.member.avatar', ['user_id' => $member->id]))
|
|
->assertSuccessful()
|
|
->assertHeader('content-type', 'image/jpeg');
|
|
});
|
|
|
|
it('does not serve the profile picture of a user who shares no team', function () {
|
|
Storage::fake('images');
|
|
$viewer = User::factory()->create();
|
|
$viewer->teams()->attach(Team::factory()->create()->id, ['role' => 'owner']);
|
|
$stranger = User::factory()->create([
|
|
'avatar_path' => 'avatars/3/avatar.jpg',
|
|
'avatar_storage_type' => 'local',
|
|
]);
|
|
$stranger->teams()->attach(Team::factory()->create()->id, ['role' => 'owner']);
|
|
Storage::disk('images')->put($stranger->avatar_path, 'avatar-content');
|
|
|
|
$this->withoutMiddleware()->actingAs($viewer)
|
|
->get(route('team.member.avatar', ['user_id' => $stranger->id]))
|
|
->assertNotFound();
|
|
});
|