1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-09-20 15:34:03 +00:00
tenancy/tests/TenantAssetTest.php
lukinovec 9cda4cb3e4 Forget scoped disk's parent no matter how nested it is
This includes moving the TenantAssetController baseDiskName() method to FSBootstrapper and making it public static, since the same logic is used in two places now. Also cover the edge case where a scoped disk A has a scoped disk B as its parent, and B has A as its parent -- in that case, the method would be stuck in an infinite loop (also added separate test for this, commenting out the $visited-related code in baseDiskName will make the test fail).

Also updated the assetRoot's unnamed disk exception message.
2026-09-07 18:32:15 -07:00

500 lines
18 KiB
PHP

<?php
declare(strict_types=1);
use Stancl\Tenancy\Tests\Etc\Tenant;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Events\TenancyInitialized;
use Stancl\Tenancy\Listeners\BootstrapTenancy;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\UrlGeneratorBootstrapper;
use Stancl\Tenancy\Controllers\TenantAssetController;
use Stancl\Tenancy\Events\TenancyEnded;
use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Overrides\TenancyUrlGenerator;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config(['tenancy.bootstrappers' => [
FilesystemTenancyBootstrapper::class,
]]);
TenancyUrlGenerator::$prefixRouteNames = false;
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
TenantAssetController::$headers = [];
TenantAssetController::$publicDisk = null;
InitializeTenancyByRequestData::$onFail = null;
/** @var CloneRoutesAsTenant $cloneAction */
$cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction->handle(Route::getRoutes()->getByName('stancl.tenancy.asset'));
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
afterEach(function () {
TenantAssetController::$headers = [];
TenantAssetController::$publicDisk = null;
InitializeTenancyByRequestData::$onFail = null;
});
test('asset can be accessed using the url returned by the tenant asset helper', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$filename = 'testfile' . Str::random(8);
Storage::disk('public')->put($filename, 'bar');
$path = storage_path("app/public/$filename");
// response()->file() returns BinaryFileResponse whose content is
// inaccessible via getContent, so ->assertSee() can't be used
expect($path)->toBeFile();
$response = pest()->get(tenant_asset($filename), [
'X-Tenant' => $tenant->id,
]);
$response->assertSuccessful();
$f = fopen($path, 'r');
$content = fread($f, filesize($path));
fclose($f);
expect($content)->toBe('bar');
});
test('tenant assets are served even when the suffix_storage_path config is set to false', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
'tenancy.filesystem.suffix_storage_path' => false,
]);
// With suffix_storage_path disabled, storage_path() stays central in tenant context
$centralStoragePath = storage_path();
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$filename = 'testfile' . Str::random(8);
Storage::disk('public')->put($filename, 'bar');
$response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
// The asset is served from the tenant's storage directory, not from the central storage path
$response->assertSuccessful();
expect($response->getFile()->getPathname())
->toBe("$centralStoragePath/tenant{$tenant->id}/app/public/$filename");
});
test('the disk used for serving tenant assets is configurable', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// This is tenancy's default override for the local disk's root, set it here for clarity
'tenancy.filesystem.root_override.local' => '%storage_path%/app/',
]);
// The local disk's root is overridden to '%storage_path%/app/' (so it does not use 'app/public')
TenantAssetController::$publicDisk = 'local';
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$filename = 'testfile' . Str::random(8);
Storage::disk('local')->put($filename, 'bar');
$path = Storage::disk('local')->path($filename);
$response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
// The asset is served from the disk's root instead of 'app/public'
$response->assertSuccessful();
expect($response->getFile()->getPathname())->toBe($path);
});
test('tenant asset controller throws when the configured disk is not local', function (string $publicDisk) {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// Add a disk that uses the s3 driver (= non-local disk).
// Use dummy credentials so that the s3 disk can be resolved without throwing an AWS exception.
'filesystems.disks.remote' => [
'driver' => 's3',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
],
'filesystems.disks.scoped_remote' => [
'driver' => 'scoped',
'disk' => 'remote',
'prefix' => 'assets',
],
]);
TenantAssetController::$publicDisk = $publicDisk;
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$this->withoutExceptionHandling();
pest()->expectExceptionMessage("Disk [$publicDisk] is not a local disk.");
pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]);
})->with([
'disk' => 'remote',
'scoped disk' => 'scoped_remote',
]);
test('tenant asset controller throws when the disk used for serving assets is not tenant-aware', function (string $publicDisk, string $expectedMessage) {
$centralStoragePath = storage_path();
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
'filesystems.disks.media' => [
'driver' => 'local',
'root' => storage_path('app/media'),
],
'filesystems.disks.scoped_media' => [
'driver' => 'scoped',
'disk' => 'media',
'prefix' => 'assets',
],
'filesystems.disks.inline_scoped_media' => [
'driver' => 'scoped',
// Laravel allows configuring the parent disk inline, but such a disk
// has no name, so it cannot be listed in tenancy.filesystem.disks (i.e. made tenant-aware)
'disk' => [
'driver' => 'local',
'root' => storage_path('app/media'),
],
'prefix' => 'assets',
],
// 'media' isn't tenant-aware (i.e. not included in tenancy.filesystem.disks)
'tenancy.filesystem.root_override.media' => '%storage_path%/app/media/',
]);
TenantAssetController::$publicDisk = $publicDisk;
$tenant = Tenant::create();
tenancy()->initialize($tenant);
Storage::disk($publicDisk)->put($filename = 'testfile' . Str::random(8), 'bar');
// The disk's root stays central
expect(Storage::disk($publicDisk)->path($filename))->toStartWith("$centralStoragePath/app/media/");
$this->withoutExceptionHandling();
pest()->expectExceptionMessage($expectedMessage);
pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
})->with([
'disk' => ['media', 'Disk [media] is not tenant-aware.'],
'scoped disk' => ['scoped_media', 'Disk [media] is not tenant-aware.'],
'scoped disk with an inline parent disk' => ['inline_scoped_media', 'Disk [inline_scoped_media] has an unnamed parent disk.'],
]);
test('tenant assets are served from the resolved root of a scoped disk', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// A scoped disk has no configured root -- it inherits the root of its parent disk
// and appends its prefix to it, both of which happens when the disk is resolved.
'filesystems.disks.scoped_disk' => [
'driver' => 'scoped',
'disk' => 'public',
'prefix' => 'scoped_disk_prefix',
],
// Default tenancy config, set it here for clarity
'tenancy.filesystem.disks' => ['local', 'public'],
]);
TenantAssetController::$publicDisk = 'scoped_disk';
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$filename = 'testfile' . Str::random(8);
Storage::disk('scoped_disk')->put($filename, 'bar');
$path = Storage::disk('scoped_disk')->path($filename);
// The parent disk is tenant-aware, so the scoped disk's root is inside the tenant's storage directory
expect($path)->toBe(storage_path("app/public/scoped_disk_prefix/$filename"));
$response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
$response->assertSuccessful();
expect($response->getFile()->getPathname())->toBe($path);
});
test('tenant assets are served from the resolved root of a disk with a configured prefix', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// A prefix is part of the disk's root path, so it has to be included in the asset root
'filesystems.disks.prefixed' => [
'driver' => 'local',
'root' => storage_path('app/media'),
'prefix' => 'foo-prefix',
],
'tenancy.filesystem.disks' => ['prefixed'],
'tenancy.filesystem.root_override.prefixed' => '%storage_path%/app/media/',
]);
TenantAssetController::$publicDisk = 'prefixed';
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$filename = 'testfile' . Str::random(8);
Storage::disk('prefixed')->put($filename, 'bar');
$path = Storage::disk('prefixed')->path($filename);
expect($path)->toBe(storage_path("app/media/foo-prefix/$filename"));
$response = pest()->get(tenant_asset($filename), ['X-Tenant' => $tenant->id]);
$response->assertSuccessful();
expect($response->getFile()->getPathname())->toBe($path);
});
test('tenant assets are served from the central storage path in central context', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
// Mimic the universal route setup (let the request through even though no tenant is identified)
InitializeTenancyByRequestData::$onFail = fn ($e, $request, $next) => $next($request);
$filename = 'testfile' . Str::random(8);
Storage::disk('public')->put($filename, 'bar');
$response = pest()->get(tenant_asset($filename));
$response->assertSuccessful();
expect($response->getFile()->getPathname())->toBe(storage_path("app/public/$filename"));
});
test('asset helper returns a link to tenant asset controller when asset url is null', function () {
config(['app.asset_url' => null]);
config(['tenancy.filesystem.asset_helper_override' => true]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
expect(asset('foo'))->toBe(route('stancl.tenancy.asset', ['path' => 'foo']));
tenancy()->end();
expect(asset('foo'))->toBe('http://localhost/foo');
});
test('asset helper returns a link to an external url when asset url is not null', function () {
config(['app.asset_url' => 'https://an-s3-bucket']);
config(['tenancy.filesystem.asset_helper_override' => true]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
expect(asset('foo'))->toBe("https://an-s3-bucket/tenant{$tenant->id}/foo");
tenancy()->end();
expect(asset('foo'))->toBe('https://an-s3-bucket/foo');
});
test('asset helper works correctly with path identification', function (bool $kernelIdentification) {
TenancyUrlGenerator::$prefixRouteNames = true;
TenancyUrlGenerator::$passTenantParameterToRoutes = true;
config(['tenancy.filesystem.asset_helper_override' => true]);
config(['tenancy.identification.default_middleware' => InitializeTenancyByPath::class]);
config(['tenancy.bootstrappers' => array_merge([UrlGeneratorBootstrapper::class], config('tenancy.bootstrappers'))]);
$tenantAssetRoute = Route::prefix('{tenant}')->get('/tenant_helper', function () {
return tenant_asset('foo');
})->name('tenant.helper.tenant');
$assetRoute = Route::prefix('{tenant}')->get('/asset_helper', function () {
return asset('foo');
})->name('tenant.helper.asset');
if ($kernelIdentification) {
app(Kernel::class)->pushMiddleware(InitializeTenancyByPath::class);
} else {
$assetRoute->middleware(InitializeTenancyByPath::class);
$tenantAssetRoute->middleware(InitializeTenancyByPath::class);
}
/** @var CloneRoutesAsTenant $cloneAction */
$cloneAction = app(CloneRoutesAsTenant::class);
$cloneAction->handle();
tenancy()->initialize(Tenant::create());
expect(pest()->get(route('tenant.helper.asset'))->getContent())->toBe(route('stancl.tenancy.asset', ['path' => 'foo']));
expect(pest()->get(route('tenant.helper.tenant'))->getContent())->toBe(route('stancl.tenancy.asset', ['path' => 'foo']));
})->with([
'kernel identification' => true,
'route-level identification' => false,
]);
test('TenantAssetController headers are configurable', function () {
TenantAssetController::$headers = function (Request $request) {
return ['X-Foo' => 'Bar'];
};
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$tenant->createDomain('foo.localhost');
$filename = 'testfile' . Str::random(10);
Storage::disk('public')->put($filename, 'bar');
$this->withoutExceptionHandling();
$response = pest()->get("http://foo.localhost/tenancy/assets/$filename", [
'X-Tenant' => $tenant->id,
]);
$response->assertSuccessful();
$response->assertHeader('X-Foo', 'Bar');
});
test('global asset helper returns the same url regardless of tenancy initialization', function () {
$original = global_asset('foobar');
expect(global_asset('foobar'))->toBe(asset('foobar'));
$tenant = Tenant::create();
tenancy()->initialize($tenant);
expect(global_asset('foobar'))->toBe($original);
});
test('asset helper tenancy can be disabled', function () {
$original = asset('foo');
config([
'app.asset_url' => null,
'tenancy.filesystem.asset_helper_override' => false,
]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
expect(asset('foo'))->toBe($original);
});
test('test asset controller returns a 404 when no path is provided', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$this->withoutExceptionHandling();
pest()->expectExceptionMessage('Empty path'); // outside tests this is a 404
pest()->get(tenant_asset(null), [
'X-Tenant' => $tenant->id,
])->assertNotFound();
});
test('tenant asset controller returns a 404 when the storage root doesnt exist', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$storageRoot = storage_path("app/public");
if (is_dir($storageRoot)) {
rmdir(storage_path("app/public"));
}
$this->withoutExceptionHandling();
pest()->expectExceptionMessage("Storage root doesn't exist"); // outside tests this is a 404
pest()->get(tenant_asset('foo.txt'), [
'X-Tenant' => $tenant->id,
]);
});
test('tenant asset controller returns a 404 when accessing a nonexistent file', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$storageRoot = storage_path("app/public");
if (! is_dir($storageRoot)) {
mkdir(storage_path("app/public"), recursive: true);
}
$this->withoutExceptionHandling();
pest()->expectExceptionMessage("Accessing a nonexistent file"); // outside tests this is a 404
pest()->get(tenant_asset('foo.txt'), [
'X-Tenant' => $tenant->id,
]);
});
test('tenant asset controller throws an exception when accessing a file in a directory whose name starts with the name of the asset root', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
// Disk used for serving the assets -- its root is 'app/media' in the tenant's storage directory
'filesystems.disks.media' => ['driver' => 'local', 'root' => storage_path('app/media')],
'tenancy.filesystem.disks' => array_merge(config('tenancy.filesystem.disks'), ['media']),
'tenancy.filesystem.root_override.media' => '%storage_path%/app/media/',
]);
TenantAssetController::$publicDisk = 'media';
$tenant = Tenant::create();
tenancy()->initialize($tenant);
Storage::disk('media')->put('photo.jpg', 'public file');
pest()->get(tenant_asset('photo.jpg'), [
'X-Tenant' => $tenant->id,
])->assertSuccessful();
// A directory next to the asset root, e.g. one holding files that shouldn't be served
mkdir($privateDirectory = storage_path('app/media-originals'), recursive: true);
file_put_contents($privateDirectory . '/photo.jpg', 'private file');
$this->withoutExceptionHandling();
pest()->expectExceptionMessage('Accessing a file outside the storage root'); // outside tests this is a 404
pest()->get(tenant_asset('../media-originals/photo.jpg'), [
'X-Tenant' => $tenant->id,
]);
});
test('test asset controller returns a 404 when accessing a file outside the storage root', function () {
config(['tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$storageRoot = storage_path("app/public");
if (! is_dir($storageRoot)) {
mkdir(storage_path("app/public"), recursive: true);
file_put_contents(storage_path('app/foo.txt'), 'bar');
}
$this->withoutExceptionHandling();
pest()->expectExceptionMessage('Accessing a file outside the storage root'); // outside tests this is a 404
pest()->get(tenant_asset('../foo.txt'), [
'X-Tenant' => $tenant->id,
]);
});