Files
projectsend/tests/Feature/Platform/ExternalStorageSettingsTest.php
T
ignacionelson 0671848bfa Read settings written before the columns they name existed
Reported by @apps3000 in #1770. Upgrading a container from 2.0 or 2.1
with external storage configured restart-loops, and says the database is
unreachable while the database is fine.

A row hydrated from the database does not get the model's column
defaults — only a new model does. So a row written before
external_storage_settings.provider existed reads that column as null,
and the enum match in isConfigured() throws UnhandledMatchError.

That would be a small bug anywhere else. It is not here, because
PlatformServiceProvider::boot() reads these settings on every process
boot, and boot happens before `artisan migrate` runs. During an upgrade
the code is new and the schema is still old, so every artisan command in
that window dies — including `projectsend:update`, the one that would
have added the column. Reordering the entrypoint or using a lighter
readiness probe does not help for that reason; the crash is in the
bootstrap, not in the probe.

current() now applies the model's declared defaults to any column the
hydrated row does not have. That closes the window for every column with
a default rather than for the one where it was found, and goes inert the
moment the schema is current. The match in isConfigured() is left total
on purpose: a default arm would swallow a real unhandled case, and the
invariant it needs now holds at the one place the row is read.

The probe's message is the other half. It boots the whole application,
so it fails both when the database is absent and when the application
cannot start, and it reported the second as the first — sending an
operator off checking credentials that were never wrong. It now prints
the error it actually hit and says which of the two it looks like.

Verified end to end against a 2.1-shaped database: `artisan migrate`
dies with UnhandledMatchError before the change and completes after it,
leaving the row reading as S3 with its bucket intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 17:23:26 -03:00

500 lines
19 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
$this->admin = User::factory()->create();
});
test('the secret is encrypted at rest', function () {
ExternalStorageSettings::current()->fill(['secret' => 'super-secret-access-key'])->save();
$raw = DB::table('external_storage_settings')->value('secret');
expect($raw)->not->toBe('super-secret-access-key')
->and(ExternalStorageSettings::current()->secret)->toBe('super-secret-access-key');
});
test('the secret never reaches the cache store', function () {
// The column above is only half the promise: this class caches its
// resolved settings forever on every process boot, and a cache store
// encrypts nothing. See the same pair in MailProviderSettingsTest.
ExternalStorageSettings::current()->fill([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => 'super-secret-access-key',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
Cache::flush();
app(ExternalStorageConfigApplier::class)->apply();
$cached = Cache::get('platform.external_storage_settings.v4');
expect($cached)->toBeArray()
->and(json_encode($cached))->not->toContain('super-secret-access-key');
});
test('the secret never reaches the database cache store either', function () {
// Against the store config/cache.php actually defaults to, read as the
// raw row an operator would find in a dump.
config(['cache.default' => 'database']);
ExternalStorageSettings::current()->fill([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => 'super-secret-access-key',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
Cache::store('database')->flush();
app(ExternalStorageConfigApplier::class)->apply();
$rows = DB::table('cache')->pluck('value')->implode('|');
expect($rows)->not->toContain('super-secret-access-key');
});
test('apply() still configures the secret it no longer caches', function () {
// The other half: keeping the credential out of the cache must not
// stop the disk from being configured with it.
ExternalStorageSettings::current()->fill([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => 'super-secret-access-key',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
expect(config('filesystems.disks.files_external.secret'))->toBe('super-secret-access-key');
});
test('apply() is a no-op when not fully configured', function () {
$originalKey = config('filesystems.disks.files_external.key');
ExternalStorageSettings::current()->fill(['active' => true, 'key' => 'AKIA...'])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
expect(config('filesystems.disks.files_external.key'))->toBe($originalKey);
});
test('apply() overrides the files_external disk config once fully configured and active', function () {
ExternalStorageSettings::current()->fill([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => 'shh',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
'endpoint' => 'https://minio.example.test',
'use_path_style' => true,
'root' => 'projectsend',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
expect(config('filesystems.disks.files_external.key'))->toBe('AKIAEXAMPLE')
->and(config('filesystems.disks.files_external.secret'))->toBe('shh')
->and(config('filesystems.disks.files_external.bucket'))->toBe('my-bucket')
->and(config('filesystems.disks.files_external.region'))->toBe('us-east-1')
->and(config('filesystems.disks.files_external.endpoint'))->toBe('https://minio.example.test')
->and(config('filesystems.disks.files_external.use_path_style_endpoint'))->toBeTrue()
->and(config('filesystems.disks.files_external.root'))->toBe('projectsend');
});
test('the ResolvingUploadDisk listener leaves new uploads on the local disk when not configured', function () {
app(ExternalStorageConfigApplier::class)->flush();
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files');
});
test('the ResolvingUploadDisk listener redirects new uploads to files_external once configured and active', function () {
ExternalStorageSettings::current()->fill([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => 'shh',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files_external');
});
test('a real upload lands on the external disk once the backend is active, and local uploads made before activation are unaffected', function () {
Storage::fake('files');
Storage::fake('files_external');
uploadImageFile($this->admin);
$localFile = File::query()->latest('id')->first();
expect($localFile->disk)->toBe('files');
Storage::disk('files')->assertExists($localFile->path);
ExternalStorageSettings::current()->fill([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => 'shh',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
uploadImageFile($this->admin);
$externalFile = File::query()->latest('id')->first();
expect($externalFile->id)->not->toBe($localFile->id)
->and($externalFile->disk)->toBe('files_external');
Storage::disk('files_external')->assertExists($externalFile->path);
// The first file, uploaded before activation, still resolves to local
// disk and is unaffected by the backend switch (no migration tool).
Storage::disk('files')->assertExists($localFile->refresh()->path);
expect($localFile->disk)->toBe('files');
});
test('apply() supplies no credentials at all when authenticating as the server role', function () {
// Nothing to leave blank and nothing to read: Laravel only builds a
// `credentials` entry for the S3 client when both halves are
// non-empty, and the AWS SDK falls back to its default credential
// provider chain when none is given. An empty string here would be a
// credential, and would fail instead of falling through.
ExternalStorageSettings::current()->fill([
'active' => true,
'use_instance_role' => true,
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
expect(config('filesystems.disks.files_external.key'))->toBeNull()
->and(config('filesystems.disks.files_external.secret'))->toBeNull()
->and(config('filesystems.disks.files_external.bucket'))->toBe('my-bucket')
->and(config('filesystems.disks.files_external.region'))->toBe('us-east-1');
});
test('a leftover stored secret is never applied once the server role is authenticating', function () {
// The row is written straight here, bypassing the controller that
// clears the credential — the runtime must not fall back to a secret
// it was told to stop using.
ExternalStorageSettings::current()->fill([
'active' => true,
'use_instance_role' => true,
'key' => 'AKIALEFTOVER',
'secret' => 'leftover-secret',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
expect(config('filesystems.disks.files_external.key'))->toBeNull()
->and(config('filesystems.disks.files_external.secret'))->toBeNull();
});
test('new uploads go to the external disk with no stored credentials when the server role is authenticating', function () {
// The silent failure this guards: isConfigured() demanding a key and
// a secret would leave the disk "unconfigured" forever, and every
// upload would keep landing on the local disk without saying so.
ExternalStorageSettings::current()->fill([
'active' => true,
'use_instance_role' => true,
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files_external');
});
test('booting against a row older than the provider column does not throw', function () {
// #1770. The exact shape of a 2.0/2.1 install with S3 configured,
// upgrading to 2.2 or later: the code is new, the schema is still old,
// and boot happens before `artisan migrate` gets a chance to run.
//
// The failure this pins was not a wrong answer but a fatal one — an
// UnhandledMatchError on a null provider, thrown during
// PlatformServiceProvider::boot(), which took down every artisan
// command including the one that would have added the column. The
// official image reported it as "database unreachable" and
// restart-looped.
legacyStorageRow();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
// Read as S3, which is the only thing a row predating the column could
// have been — and is what the migration's own default says.
expect(config('filesystems.disks.files_external.bucket'))->toBe('my-bucket')
->and(config('filesystems.disks.files_external.key'))->toBe('AKIAEXAMPLE')
->and(config('filesystems.disks.files_external.driver'))->toBe('s3');
});
test('an upload still reaches the external disk while the schema is mid-upgrade', function () {
// The other half: not throwing is not the same as behaving. An
// installation that was storing files in a bucket before the upgrade
// must not quietly start writing them to local disk during it.
legacyStorageRow();
app(ExternalStorageConfigApplier::class)->flush();
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files_external');
});
test('staff can save external storage settings', function () {
config()->set('projectsend.edition', Edition::Community);
$this->actingAs($this->admin)->patch('/system/settings/storage', validStorageSettingsPayload([
'access_key' => 'AKIANEW',
'bucket' => 'renamed-bucket',
]))->assertRedirect();
$settings = ExternalStorageSettings::current();
expect($settings->key)->toBe('AKIANEW')
->and($settings->bucket)->toBe('renamed-bucket');
});
test('saving with a blank secret keeps the previously stored secret', function () {
$this->actingAs($this->admin)->patch('/system/settings/storage', validStorageSettingsPayload([
'secret' => 'first-secret',
]));
$this->actingAs($this->admin)->patch('/system/settings/storage', validStorageSettingsPayload([
'secret' => '',
'bucket' => 'renamed-bucket',
]));
$settings = ExternalStorageSettings::current();
expect($settings->secret)->toBe('first-secret')
->and($settings->bucket)->toBe('renamed-bucket');
});
test('saving with the server role asked for needs no access key, and deletes the stored credentials', function () {
$this->actingAs($this->admin)->patch('/system/settings/storage', validStorageSettingsPayload([
'access_key' => 'AKIAEXAMPLE',
'secret' => 'shh',
]))->assertRedirect();
$payload = validStorageSettingsPayload(['use_instance_role' => true]);
unset($payload['access_key'], $payload['secret']);
$this->actingAs($this->admin)->patch('/system/settings/storage', $payload)
->assertSessionHasNoErrors();
$settings = ExternalStorageSettings::current();
expect($settings->use_instance_role)->toBeTrue()
->and($settings->key)->toBeNull()
->and($settings->secret)->toBeNull()
->and($settings->isConfigured())->toBeTrue();
// And nothing is left in the row for a database dump to carry.
expect(DB::table('external_storage_settings')->value('secret'))->toBeNull();
});
test('an access key is still required when it is missing rather than blank', function () {
// Not the same case as an empty string: a validation rule that only
// runs on a present attribute would let this through.
$payload = validStorageSettingsPayload();
unset($payload['access_key']);
$this->actingAs($this->admin)->patch('/system/settings/storage', $payload)
->assertSessionHasErrors(['access_key']);
});
test('the connection test runs without an access key when the server role is authenticating', function () {
// The button has to accept the same configuration the save does —
// otherwise there is no way to check an IAM-role setup before
// switching uploads over to it, which is the whole point of it.
//
// Credentials are put in the environment so the SDK's default chain
// resolves instantly from there rather than reaching for the EC2
// metadata service, and the endpoint is a closed local port so the
// request fails at once. This asserts the request is *made*, offline
// and in well under a second — not that a bucket exists.
putenv('AWS_ACCESS_KEY_ID=AKIAENVEXAMPLE');
putenv('AWS_SECRET_ACCESS_KEY=env-secret');
try {
$response = $this->actingAs($this->admin)->post('/system/settings/storage/test', [
'provider' => 's3',
'use_instance_role' => true,
'bucket' => 'my-bucket',
'region' => 'us-east-1',
'endpoint' => 'http://127.0.0.1:1',
'use_path_style' => true,
]);
$response->assertRedirect()->assertSessionHasNoErrors();
expect(session('storage_test_result'))->toBeString();
} finally {
putenv('AWS_ACCESS_KEY_ID');
putenv('AWS_SECRET_ACCESS_KEY');
}
});
test('saving external storage settings rejects invalid input', function () {
$this->actingAs($this->admin)->patch('/system/settings/storage', validStorageSettingsPayload([
'access_key' => '',
'bucket' => '',
'region' => '',
]))->assertSessionHasErrors(['access_key', 'bucket', 'region']);
});
test('saving external storage settings signals the queue worker to restart', function () {
Cache::forget('illuminate:queue:restart');
$this->actingAs($this->admin)->patch('/system/settings/storage', validStorageSettingsPayload());
expect(Cache::get('illuminate:queue:restart'))->not->toBeNull();
});
test('clients cannot save external storage settings', function () {
$this->actingAs(User::factory()->client()->create())
->patch('/system/settings/storage', validStorageSettingsPayload())
->assertForbidden();
});
test('the entire storage settings surface is unavailable in the cloud edition', function () {
config()->set('projectsend.edition', Edition::Cloud);
$this->actingAs($this->admin);
$this->get('/system/settings/storage')->assertNotFound();
$this->patch('/system/settings/storage', validStorageSettingsPayload())->assertNotFound();
});
test('cloud edition never honors a stored external storage backend, even one written outside the form', function () {
config()->set('projectsend.edition', Edition::Cloud);
// Simulate a stray/legacy row, bypassing the controller entirely —
// the runtime gate must hold regardless of how it got there.
ExternalStorageSettings::query()->create([
'active' => true,
'key' => 'AKIASTRAY',
'secret' => 'stray-secret',
'bucket' => 'stray-bucket',
'region' => 'us-east-1',
]);
app(ExternalStorageConfigApplier::class)->flush();
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files');
});
test('a value cached while running as Community cannot leak into Cloud without an explicit flush', function () {
// Warm the cache while Community is active and the backend is fully
// configured — this is the exact scenario found manually: nothing
// besides saving the settings form ever calls flush(), so switching
// editions alone must not be enough to keep the old behavior alive.
config()->set('projectsend.edition', Edition::Community);
ExternalStorageSettings::current()->fill([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => 'shh',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
expect(config('filesystems.disks.files_external.bucket'))->toBe('my-bucket');
// Switch to Cloud — deliberately WITHOUT calling flush() again, since
// nothing in the app does that on an edition change either.
config()->set('projectsend.edition', Edition::Cloud);
config(['filesystems.disks.files_external.bucket' => '']);
app(ExternalStorageConfigApplier::class)->apply();
expect(config('filesystems.disks.files_external.bucket'))->toBe('');
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files');
});
/**
* An external storage row exactly as 2.0/2.1 wrote it: S3 credentials in
* place, and none of the columns added since.
*
* Written with the query builder and then stripped back, rather than
* hand-rolled SQL, so it stays honest if the table changes shape again.
*/
function legacyStorageRow(): void
{
DB::table('external_storage_settings')->insert([
'active' => true,
'key' => 'AKIAEXAMPLE',
'secret' => Crypt::encryptString('shh'),
'bucket' => 'my-bucket',
'region' => 'us-east-1',
'use_path_style' => false,
]);
Schema::table('external_storage_settings', function (Blueprint $table) {
$table->dropColumn(['provider', 'key_file', 'use_instance_role']);
});
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
function validStorageSettingsPayload(array $overrides = []): array
{
return array_merge([
'active' => true,
'access_key' => 'AKIAEXAMPLE',
'secret' => 'shh',
'bucket' => 'my-bucket',
'region' => 'us-east-1',
'endpoint' => null,
'use_path_style' => false,
'root' => null,
], $overrides);
}