1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-09-20 19:24:03 +00:00
tenancy/tests/TenantAssetTest.php
lukinovec ff3a2a3e2c Assert that the tenant asset controller only serves files inside the configured disk's root (regression test)
Currently this fails because the controller checks that the requested file is inside the
asset root using a plain string prefix, so with the root set to
'%storage_path%/app/media/', a request for '../media-originals/photo.jpg' is served
from the sibling 'app/media-originals' directory.
2026-09-07 18:32:15 -07:00

349 lines
12 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;
/** @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);
});
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 has no root', function () {
config([
'tenancy.identification.default_middleware' => InitializeTenancyByRequestData::class,
'filesystems.disks.rootless' => ['driver' => 's3'],
]);
TenantAssetController::$publicDisk = 'rootless';
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$this->withoutExceptionHandling();
pest()->expectExceptionMessage('Disk [rootless] has no root path configured.');
pest()->get(tenant_asset('foo.txt'), ['X-Tenant' => $tenant->id]);
});
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');
TenantAssetController::$headers = []; // reset static property
});
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');
// 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,
]);
});