You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
pixelfed/tests/Feature/Account/MediaMoveStorageTest.php

230 lines
8.9 KiB
PHP

<?php
use App\Models\Media;
use App\Models\Status;
use App\Models\User;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Media storage migration commands
|--------------------------------------------------------------------------
|
| admin:MediaMoveStorageLocalToCloud / unstable:MediaMoveStorageCloudToLocal
| Move media between local and cloud disks, verify by size/sha256, GC the
| source, and manage the PF_ENABLE_CLOUD .env flag for hot migrations.
|
*/
beforeEach(function () {
Config::set('filesystems.cloud', 's3');
Storage::fake('local');
Storage::fake('s3', ['url' => 'https://cdn.test']);
// Use a throwaway env file so the command's env edits don't touch the
// real one. App::useEnvironmentPath expects a directory; the app resolves
// the environment-specific filename (e.g. .env.testing) itself.
$this->originalEnvPath = app()->environmentPath();
$dir = sys_get_temp_dir().'/pf-env-test-'.uniqid();
mkdir($dir);
app()->useEnvironmentPath($dir);
file_put_contents(app()->environmentFilePath(), "APP_KEY=base64:test\nPF_ENABLE_CLOUD=false\n");
});
afterEach(function () {
// Restore the real environment path so we don't leak into other test files.
if (isset($this->originalEnvPath)) {
app()->useEnvironmentPath($this->originalEnvPath);
}
});
function makeCloudMedia(): Media
{
$user = User::factory()->create();
$user->refresh();
$pid = $user->profile->id;
$status = Status::factory()->create(['profile_id' => $pid, 'type' => 'photo']);
$path = 'public/m/_v2/'.$pid.'/aa/bb/file.jpg';
$thumb = 'public/m/_v2/'.$pid.'/aa/bb/file_thumb.jpeg';
// Put the files on the cloud disk only.
Storage::disk('s3')->put($path, 'PRIMARY-BYTES-1234567890');
Storage::disk('s3')->put($thumb, 'THUMB-BYTES');
return Media::create([
'status_id' => $status->id,
'profile_id' => $pid,
'user_id' => $user->id,
'media_path' => $path,
'thumbnail_path' => $thumb,
'cdn_url' => Storage::disk('s3')->url($path),
'thumbnail_url' => Storage::disk('s3')->url($thumb),
'optimized_url' => Storage::disk('s3')->url($path),
'mime' => 'image/jpeg',
'size' => strlen('PRIMARY-BYTES-1234567890'),
'remote_media' => false,
'version' => 4,
'replicated_at' => now(),
'order' => 0,
]);
}
describe('unstable:MediaMoveStorageCloudToLocal', function () {
it('downloads cloud media to local, clears cloud urls and deletes the cloud copy', function () {
$media = makeCloudMedia();
$this->artisan('unstable:MediaMoveStorageCloudToLocal', ['--force' => true])
->assertExitCode(0);
// File is now on local disk.
expect(Storage::disk('local')->exists($media->media_path))->toBeTrue();
// Cloud copy removed (GC), thumbnail too.
expect(Storage::disk('s3')->exists($media->media_path))->toBeFalse();
$media->refresh();
expect($media->cdn_url)->toBeNull();
expect($media->optimized_url)->toBeNull();
expect($media->thumbnail_url)->toBeNull();
expect($media->replicated_at)->toBeNull();
expect((string) $media->version)->toBe('3');
});
it('keeps the cloud copy with --keep-cloud', function () {
$media = makeCloudMedia();
$this->artisan('unstable:MediaMoveStorageCloudToLocal', ['--force' => true, '--keep-cloud' => true])
->assertExitCode(0);
expect(Storage::disk('local')->exists($media->media_path))->toBeTrue();
expect(Storage::disk('s3')->exists($media->media_path))->toBeTrue();
});
it('does not modify anything in dry-run', function () {
$media = makeCloudMedia();
$this->artisan('unstable:MediaMoveStorageCloudToLocal', ['--force' => true, '--dry-run' => true])
->assertExitCode(0);
expect(Storage::disk('local')->exists($media->media_path))->toBeFalse();
expect($media->fresh()->cdn_url)->not->toBeNull();
});
it('sets PF_ENABLE_CLOUD=false in .env and runtime when cloud is enabled', function () {
// Start with cloud enabled.
file_put_contents(app()->environmentFilePath(), "APP_KEY=base64:test\nPF_ENABLE_CLOUD=true\n");
Config::set('pixelfed.cloud_storage', true);
makeCloudMedia();
$this->artisan('unstable:MediaMoveStorageCloudToLocal', ['--force' => true])
->assertExitCode(0);
expect(file_get_contents(app()->environmentFilePath()))->toContain('PF_ENABLE_CLOUD="false"');
expect(config('pixelfed.cloud_storage'))->toBeFalse();
});
});
describe('admin:MediaMoveStorageLocalToCloud', function () {
it('requires a configured cloud disk', function () {
// Fake s3 disk has no url() host resolvable? Storage::fake provides a
// url, so instead point cloud at a disk that throws.
Config::set('filesystems.cloud', 'does-not-exist');
$this->artisan('admin:MediaMoveStorageLocalToCloud', ['--force' => true])
->assertExitCode(1);
});
it('enables cloud storage before migrating (dry-run reports it)', function () {
// cloud currently disabled (config_cache resolves falsy in tests).
Config::set('pixelfed.cloud_storage', false);
$this->artisan('admin:MediaMoveStorageLocalToCloud', ['--dry-run' => true])
->expectsOutputToContain('Cloud storage')
->assertExitCode(0);
// dry-run must not write the .env.
expect(file_get_contents(app()->environmentFilePath()))->toContain('PF_ENABLE_CLOUD=false');
});
it('does not fatal when there is no .env file (containerized deploy)', function () {
// Point the app at an environment directory with no env file, so any
// attempt to read/parse .env would throw file_get_contents() errors.
// Cloud is disabled, so the command takes the setStorageEnv() path.
$emptyDir = sys_get_temp_dir().'/pf-env-missing-'.uniqid();
mkdir($emptyDir);
app()->useEnvironmentPath($emptyDir);
expect(is_file(app()->environmentFilePath()))->toBeFalse();
Config::set('pixelfed.cloud_storage', false);
// Regression: previously threw
// "file_get_contents(.env): Failed to open stream" and exited 1.
$this->artisan('admin:MediaMoveStorageLocalToCloud', ['--force' => true])
->assertExitCode(0);
// The runtime config was still flipped even without a writable .env.
expect(config('pixelfed.cloud_storage'))->toBeTrue();
});
it('migrates media whose local file no longer matches original_sha256', function () {
// The optimize pipeline (ImageResize/ImageUpdate) rewrites the local
// file in place after upload but never updates original_sha256, so the
// stored hash describes the pre-optimization bytes. Verify must not
// compare against it, otherwise every optimized image fails verify
// (pixelfed#7203 follow-up: moved=0, failed=N).
Config::set('pixelfed.cloud_storage', true);
$media = makeLocalMediaWithStaleSha();
$this->artisan('admin:MediaMoveStorageLocalToCloud', ['--force' => true])
->assertExitCode(0);
// Uploaded to cloud and marked replicated despite the stale hash.
expect(Storage::disk('s3')->exists($media->media_path))->toBeTrue();
$media->refresh();
expect((string) $media->version)->toBe('4');
expect($media->cdn_url)->not->toBeNull();
expect($media->replicated_at)->not->toBeNull();
});
});
/**
* A local (not-yet-migrated) media row whose on-disk bytes intentionally do
* NOT match its stored original_sha256, mirroring the post-optimization state
* where the file was rewritten in place but original_sha256 kept the original
* upload's hash.
*/
function makeLocalMediaWithStaleSha(): Media
{
$user = User::factory()->create();
$user->refresh();
$pid = $user->profile->id;
$status = Status::factory()->create(['profile_id' => $pid, 'type' => 'photo']);
$path = 'public/m/_v2/'.$pid.'/cc/dd/optimized.jpg';
$currentBytes = 'OPTIMIZED-BYTES-AFTER-RESIZE';
// File on local disk holds the optimized bytes.
Storage::disk('local')->put($path, $currentBytes);
return Media::create([
'status_id' => $status->id,
'profile_id' => $pid,
'user_id' => $user->id,
'media_path' => $path,
'cdn_url' => null,
'mime' => 'image/jpeg',
'size' => strlen($currentBytes),
// Hash of some other (original upload) content, guaranteed to differ.
'original_sha256' => hash('sha256', 'ORIGINAL-UPLOAD-BYTES-BEFORE-RESIZE'),
'remote_media' => false,
'version' => 3,
'replicated_at' => null,
'order' => 0,
]);
}