1
0
Fork 0
mirror of https://github.com/archtechx/tenancy.git synced 2026-02-04 08:44:04 +00:00

Merge remote-tracking branch 'origin/august' into tenant-param-before-route-prefix

This commit is contained in:
lukinovec 2025-09-03 15:35:54 +02:00
commit d9ee7cac44
37 changed files with 344 additions and 211 deletions

View file

@ -8,14 +8,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Check for todo0
run: '! grep -r "todo0" --exclude-dir=workflows .'
if: always()
- name: Check for todo1
run: '! grep -r "todo1" --exclude-dir=workflows .'
if: always()
- name: Check for todo2
run: '! grep -r "todo2" --exclude-dir=workflows .'
- name: Check for priority todos
run: '! grep -r "todo[0-9]" --exclude-dir=workflows .'
if: always()
- name: Check for non-todo skip()s in tests
run: '! grep -r "skip(" --exclude-dir=workflows tests/ | grep -v "todo"'

View file

@ -21,6 +21,21 @@ use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByRequestData;
use Stancl\Tenancy\Bootstrappers\Integrations\FortifyRouteBootstrapper;
/**
* Tenancy for Laravel.
*
* Documentation: https://tenancyforlaravel.com
*
* We can sustainably develop Tenancy for Laravel thanks to our sponsors.
* Big thanks to everyone listed here: https://github.com/sponsors/stancl
*
* You can also support us, and save time, by purchasing these products:
* Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com
* Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate
* Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book
*
* All of these products can also be accessed at https://portal.archte.ch
*/
class TenancyServiceProvider extends ServiceProvider
{
// By default, no namespace is used to support the callable array syntax.

View file

@ -8,6 +8,21 @@ use Stancl\Tenancy\Bootstrappers;
use Stancl\Tenancy\Enums\RouteMode;
use Stancl\Tenancy\UniqueIdentifierGenerators;
/**
* Tenancy for Laravel.
*
* Documentation: https://tenancyforlaravel.com
*
* We can sustainably develop Tenancy for Laravel thanks to our sponsors.
* Big thanks to everyone listed here: https://github.com/sponsors/stancl
*
* You can also support us, and save time, by purchasing these products:
* Exclusive content for sponsors: https://sponsors.tenancyforlaravel.com
* Multi-Tenant SaaS boilerplate: https://portal.archte.ch/boilerplate
* Multi-Tenant Laravel in Production e-book: https://portal.archte.ch/book
*
* All of these products can also be accessed at https://portal.archte.ch
*/
return [
/**
* Configuration for the models used by Tenancy.

View file

@ -80,7 +80,7 @@ services:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=P@ssword # todo reuse env from above
- SA_PASSWORD=P@ssword # must be the same as TENANCY_TEST_SQLSRV_PASSWORD
healthcheck: # https://github.com/Microsoft/mssql-docker/issues/133#issuecomment-1995615432
test: timeout 2 bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/1433'
interval: 10s

View file

@ -32,10 +32,10 @@ class DatabaseTenancyBootstrapper implements TenancyBootstrapper
throw new Exception('The template connection must NOT have URL defined. Specify the connection using individual parts instead of a database URL.');
}
// Better debugging, but breaks cached lookup in prod
if (app()->environment('local') || app()->environment('testing')) { // todo@docs mention this change in v4 upgrade guide https://github.com/archtechx/tenancy/pull/945#issuecomment-1268206149
// Better debugging, but breaks cached lookup, so we disable this in prod
if (app()->environment('local') || app()->environment('testing')) {
$database = $tenant->database()->getName();
if (! $tenant->database()->manager()->databaseExists($database)) { // todo@samuel does this call correctly use the host connection?
if (! $tenant->database()->manager()->databaseExists($database)) { // todo@dbRefactor does this call correctly use the host connection?
throw new TenantDatabaseDoesNotExistException($database);
}
}

View file

@ -8,7 +8,7 @@ use Illuminate\Console\Command;
class CreatePendingTenants extends Command
{
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to be created}';
protected $signature = 'tenants:pending-create {--count= : The number of pending tenants to maintain}';
protected $description = 'Create pending tenants.';

View file

@ -51,13 +51,24 @@ class Migrate extends MigrateCommand
return 1;
}
if ($this->getProcesses() > 1) {
return $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
return $this->getTenants($chunk);
}));
$originalTemplateConnection = config('tenancy.database.template_tenant_connection');
if ($database = $this->input->getOption('database')) {
config(['tenancy.database.template_tenant_connection' => $database]);
}
return $this->migrateTenants($this->getTenants()) ? 0 : 1;
if ($this->getProcesses() > 1) {
$code = $this->runConcurrently($this->getTenantChunks()->map(function ($chunk) {
return $this->getTenants($chunk);
}));
} else {
$code = $this->migrateTenants($this->getTenants()) ? 0 : 1;
}
// Reset the template tenant connection to the original one
config(['tenancy.database.template_tenant_connection' => $originalTemplateConnection]);
return $code;
}
protected function childHandle(mixed ...$args): bool

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Commands;
use Illuminate\Console\ConfirmableTrait;
use Illuminate\Database\Console\Migrations\BaseCommand;
use Illuminate\Database\QueryException;
use Illuminate\Support\LazyCollection;
@ -17,7 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface as OI;
class MigrateFresh extends BaseCommand
{
use HasTenantOptions, DealsWithMigrations, ParallelCommand;
use HasTenantOptions, DealsWithMigrations, ParallelCommand, ConfirmableTrait;
protected $description = 'Drop all tables and re-run all migrations for tenant(s)';
@ -27,6 +28,7 @@ class MigrateFresh extends BaseCommand
$this->addOption('drop-views', null, InputOption::VALUE_NONE, 'Drop views along with tenant tables.', null);
$this->addOption('step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually.');
$this->addOption('force', null, InputOption::VALUE_NONE, 'Force the command to run when in production.', null);
$this->addProcessesOption();
$this->setName('tenants:migrate-fresh');
@ -34,6 +36,10 @@ class MigrateFresh extends BaseCommand
public function handle(): int
{
if (! $this->confirmToProceed()) {
return 1;
}
$success = true;
if ($this->getProcesses() > 1) {

View file

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Contracts;
use Stancl\Tenancy\Tenancy;
/** Additional features, like Telescope tags and tenant redirects. */
interface Feature
{
public function bootstrap(Tenancy $tenancy): void;
public function bootstrap(): void;
}

View file

@ -6,14 +6,13 @@ namespace Stancl\Tenancy\Database\Concerns;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\CreatingPendingTenant;
use Stancl\Tenancy\Events\PendingTenantCreated;
use Stancl\Tenancy\Events\PendingTenantPulled;
use Stancl\Tenancy\Events\PullingPendingTenant;
// todo consider adding a method that sets pending_since to null — to flag tenants as not-pending
/**
* @property ?Carbon $pending_since
*
@ -50,46 +49,62 @@ trait HasPending
*/
public static function createPending(array $attributes = []): Model&Tenant
{
$tenant = static::create($attributes);
event(new CreatingPendingTenant($tenant));
// Update the pending_since value only after the tenant is created so it's
// Not marked as pending until finishing running the migrations, seeders, etc.
$tenant->update([
'pending_since' => now()->timestamp,
]);
try {
$tenant = static::create($attributes);
event(new CreatingPendingTenant($tenant));
} finally {
// Update the pending_since value only after the tenant is created so it's
// not marked as pending until after migrations, seeders, etc are run.
$tenant->update([
'pending_since' => now()->timestamp,
]);
}
event(new PendingTenantCreated($tenant));
return $tenant;
}
/** Pull a pending tenant. */
public static function pullPending(): Model&Tenant
/**
* Pull a pending tenant from the pool or create a new one if the pool is empty.
*
* @param array $attributes The attributes to set on the tenant.
*/
public static function pullPending(array $attributes = []): Model&Tenant
{
/** @var Model&Tenant $pendingTenant */
$pendingTenant = static::pullPendingFromPool(true);
$pendingTenant = static::pullPendingFromPool(true, $attributes);
return $pendingTenant;
}
/** Try to pull a tenant from the pool of pending tenants. */
public static function pullPendingFromPool(bool $firstOrCreate = true, array $attributes = []): ?Tenant
/**
* Try to pull a tenant from the pool of pending tenants.
*
* @param bool $firstOrCreate If true, a tenant will be *created* if the pool is empty. Otherwise null is returned.
* @param array $attributes The attributes to set on the tenant.
*/
public static function pullPendingFromPool(bool $firstOrCreate = false, array $attributes = []): ?Tenant
{
/** @var (Model&Tenant)|null $tenant */
$tenant = static::onlyPending()->first();
$tenant = DB::transaction(function () use ($attributes): ?Tenant {
/** @var (Model&Tenant)|null $tenant */
$tenant = static::onlyPending()->first();
if ($tenant !== null) {
event(new PullingPendingTenant($tenant));
$tenant->update(array_merge($attributes, [
'pending_since' => null,
]));
}
return $tenant;
});
if ($tenant === null) {
return $firstOrCreate ? static::create($attributes) : null;
}
event(new PullingPendingTenant($tenant));
$tenant->update(array_merge($attributes, [
'pending_since' => null,
]));
// Only triggered if a tenant that was pulled from the pool is returned
event(new PendingTenantPulled($tenant));
return $tenant;

View file

@ -222,7 +222,7 @@ class DatabaseConfig
}
/**
* todo@name come up with a better name
* todo@dbRefactor come up with a better name
* Get database manager class from the given connection config's driver.
*
* @throws DatabaseManagerNotRegisteredException

View file

@ -30,7 +30,7 @@ class PermissionControlledPostgreSQLSchemaManager extends PostgreSQLSchemaManage
$tables = $this->connection()->select("SELECT table_name FROM information_schema.tables WHERE table_schema = '{$schema}' AND table_type = 'BASE TABLE'");
// Grant permissions to any existing tables. This is used with RLS
// todo@samuel refactor this along with the todo in TenantDatabaseManager
// todo@dbRefactor refactor this along with the todo in TenantDatabaseManager
// and move the grantPermissions() call inside the condition in `ManagesPostgresUsers::createUser()`
// but maybe moving it inside $createUser is wrong because some central user may migrate new tables
// while the RLS user should STILL get access to those tables

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Database\TenantDatabaseManagers;
use AssertionError;
use Closure;
use Illuminate\Database\Eloquent\Model;
use PDO;
@ -19,13 +18,6 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
*/
public static string|null $path = null;
/**
* Should the WAL journal mode be used for newly created databases.
*
* @see https://www.sqlite.org/pragma.html#pragma_journal_mode
*/
public static bool $WAL = true;
/*
* If this isn't null, a connection to the tenant DB will be created
* and passed to the provided closure, for the purpose of keeping the
@ -89,25 +81,7 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return true;
}
try {
if (file_put_contents($path = $this->getPath($name), '') === false) {
return false;
}
if (static::$WAL) {
$pdo = new PDO('sqlite:' . $path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// @phpstan-ignore-next-line method.nonObject
assert($pdo->query('pragma journal_mode = wal')->fetch(PDO::FETCH_ASSOC)['journal_mode'] === 'wal', 'Unable to set journal mode to wal.');
}
return true;
} catch (AssertionError $e) {
throw $e;
} catch (Throwable) {
return false;
}
return file_put_contents($this->getPath($name), '') !== false;
}
public function deleteDatabase(TenantWithDatabase $tenant): bool
@ -122,8 +96,16 @@ class SQLiteDatabaseManager implements TenantDatabaseManager
return true;
}
$path = $this->getPath($name);
try {
return unlink($this->getPath($name));
unlink($path . '-journal');
unlink($path . '-wal');
unlink($path . '-shm');
} catch (Throwable) {}
try {
return unlink($path);
} catch (Throwable) {
return false;
}

View file

@ -4,4 +4,9 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Events;
/**
* Importantly, listeners for this event should not switch tenancy context.
*
* This event is fired from within a database transaction.
*/
class PullingPendingTenant extends Contracts\TenantEvent {}

View file

@ -6,11 +6,10 @@ namespace Stancl\Tenancy\Features;
use Illuminate\Http\RedirectResponse;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class CrossDomainRedirect implements Feature
{
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
RedirectResponse::macro('domain', function (string $domain) {
/** @var RedirectResponse $this */

View file

@ -10,14 +10,12 @@ use Illuminate\Database\SQLiteConnection;
use Illuminate\Support\Facades\DB;
use PDO;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class DisallowSqliteAttach implements Feature
{
protected static bool|null $loadExtensionSupported = null;
public static string|false|null $extensionPath = null;
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
// Handle any already resolved connections
foreach (DB::getConnections() as $connection) {
@ -39,16 +37,12 @@ class DisallowSqliteAttach implements Feature
protected function loadExtension(PDO $pdo): bool
{
if (static::$loadExtensionSupported === null) {
static::$loadExtensionSupported = method_exists($pdo, 'loadExtension');
}
static $loadExtensionSupported = method_exists($pdo, 'loadExtension');
if (static::$loadExtensionSupported === false) {
return false;
}
if (static::$extensionPath === false) {
return false;
}
if ((! $loadExtensionSupported) ||
(static::$extensionPath === false) ||
(PHP_INT_SIZE !== 8)
) return false;
$suffix = match (PHP_OS_FAMILY) {
'Linux' => 'so',
@ -61,9 +55,7 @@ class DisallowSqliteAttach implements Feature
$arm = $arch === 'aarch64' || $arch === 'arm64';
static::$extensionPath ??= realpath(base_path('vendor/stancl/tenancy/extensions/lib/' . ($arm ? 'arm/' : '') . 'noattach.' . $suffix));
if (static::$extensionPath === false) {
return false;
}
if (static::$extensionPath === false) return false;
$pdo->loadExtension(static::$extensionPath); // @phpstan-ignore method.notFound

View file

@ -7,11 +7,10 @@ namespace Stancl\Tenancy\Features;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Tenancy;
class TelescopeTags implements Feature
{
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
if (! class_exists(Telescope::class)) {
return;

View file

@ -12,7 +12,6 @@ use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Events\RevertedToCentralContext;
use Stancl\Tenancy\Events\TenancyBootstrapped;
use Stancl\Tenancy\Tenancy;
class TenantConfig implements Feature
{
@ -27,7 +26,7 @@ class TenantConfig implements Feature
protected Repository $config,
) {}
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
Event::listen(TenancyBootstrapped::class, function (TenancyBootstrapped $event) {
/** @var Tenant $tenant */

View file

@ -17,9 +17,9 @@ class UserImpersonation implements Feature
/** The lifespan of impersonation tokens (in seconds). */
public static int $ttl = 60;
public function bootstrap(Tenancy $tenancy): void
public function bootstrap(): void
{
$tenancy->macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
Tenancy::macro('impersonate', function (Tenant $tenant, string $userId, string $redirectUrl, string|null $authGuard = null, bool $remember = false): Model {
return UserImpersonation::modelClass()::create([
Tenancy::tenantKeyColumn() => $tenant->getTenantKey(),
'user_id' => $userId,

View file

@ -5,22 +5,19 @@ declare(strict_types=1);
namespace Stancl\Tenancy\Features;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Vite;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Overrides\Vite;
use Stancl\Tenancy\Tenancy;
class ViteBundler implements Feature
{
/** @var Application */
protected $app;
public function __construct(
protected Application $app,
) {}
public function __construct(Application $app)
public function bootstrap(): void
{
$this->app = $app;
}
public function bootstrap(Tenancy $tenancy): void
{
$this->app->singleton(\Illuminate\Foundation\Vite::class, Vite::class);
Vite::createAssetPathsUsing(function ($path, $secure = null) {
return global_asset($path);
});
}
}

View file

@ -11,8 +11,6 @@ use Stancl\Tenancy\Concerns\UsableWithEarlyIdentification;
use Stancl\Tenancy\Enums\RouteMode;
/**
* todo@name come up with a better name.
*
* Prevents accessing central domains in the tenant context/tenant domains in the central context.
* The access isn't prevented if the request is trying to access a route flagged as 'universal',
* or if this middleware should be skipped.
@ -68,9 +66,11 @@ class PreventAccessFromUnwantedDomains
return in_array($request->getHost(), config('tenancy.identification.central_domains'), true);
}
// todo@samuel technically not an identification middleware but probably ok to keep this here
public function requestHasTenant(Request $request): bool
{
// This middleware is special in that it's not an identification middleware
// but still uses some logic from UsableWithEarlyIdentification, so we just
// need to implement this method here. It doesn't matter what it returns.
return false;
}
}

View file

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace Stancl\Tenancy\Overrides;
use Illuminate\Foundation\Vite as BaseVite;
class Vite extends BaseVite
{
/**
* Generate an asset path for the application.
*
* @param string $path
* @param bool|null $secure
* @return string
*/
protected function assetPath($path, $secure = null)
{
return global_asset($path);
}
}

View file

@ -11,6 +11,7 @@ use Illuminate\Foundation\Bus\PendingDispatch;
use Illuminate\Support\Traits\Macroable;
use Stancl\Tenancy\Concerns\DealsWithRouteContexts;
use Stancl\Tenancy\Concerns\ManagesRLSPolicies;
use Stancl\Tenancy\Contracts\Feature;
use Stancl\Tenancy\Contracts\TenancyBootstrapper;
use Stancl\Tenancy\Contracts\Tenant;
use Stancl\Tenancy\Exceptions\TenantCouldNotBeIdentifiedByIdException;
@ -24,7 +25,11 @@ class Tenancy
*/
public Tenant|null $tenant = null;
// todo@docblock
/**
* Custom callback for providing a list of bootstrappers to use.
* When this is null, config('tenancy.bootstrappers') is used.
* @var ?Closure(): list<TenancyBootstrapper>
*/
public ?Closure $getBootstrappersUsing = null;
/** Is tenancy fully initialized? */
@ -36,7 +41,7 @@ class Tenancy
public static array $findWith = [];
/**
* A list of bootstrappers that have been initialized.
* List of bootstrappers that have been initialized.
*
* This is used when reverting tenancy, mainly if an exception
* occurs during bootstrapping, to ensure we don't revert
@ -49,6 +54,23 @@ class Tenancy
*/
public array $initializedBootstrappers = [];
/**
* List of features that have been bootstrapped.
*
* Since features may be bootstrapped multiple times during
* the request cycle (in TSP::boot() and any other times the user calls
* bootstrapFeatures()), we keep track of which features have already
* been bootstrapped so we do not bootstrap them again. Features are
* bootstrapped once and irreversible.
*
* The main point of this is that some features *need* to be bootstrapped
* very early (see #949), so we bootstrap them directly in TSP, but we
* also need the ability to *change* which features are used at runtime
* (mainly tests of this package) and bootstrap features again after making
* changes to config('tenancy.features').
*/
protected array $bootstrappedFeatures = [];
/** Initialize tenancy for the passed tenant. */
public function initialize(Tenant|int|string $tenant): void
{
@ -131,12 +153,12 @@ class Tenancy
/** @return TenancyBootstrapper[] */
public function getBootstrappers(): array
{
// If no callback for getting bootstrappers is set, we just return all of them.
$resolve = $this->getBootstrappersUsing ?? function (Tenant $tenant) {
// If no callback for getting bootstrappers is set, we return the ones in config.
$resolve = $this->getBootstrappersUsing ?? function (?Tenant $tenant) {
return config('tenancy.bootstrappers');
};
// Here We instantiate the bootstrappers and return them.
// Here we instantiate the bootstrappers and return them.
return array_map('app', $resolve($this->tenant));
}
@ -150,6 +172,26 @@ class Tenancy
return in_array($bootstrapper, static::getBootstrappers(), true);
}
/**
* Bootstrap configured Tenancy features.
*
* Normally, features are bootstrapped directly in TSP::boot(). However, if
* new features are enabled at runtime (e.g. during tests), this method may
* be called to bootstrap new features. It's idempotent and keeps track of
* which features have already been bootstrapped. Keep in mind that feature
* bootstrapping is irreversible.
*/
public function bootstrapFeatures(): void
{
foreach (config('tenancy.features') ?? [] as $feature) {
/** @var class-string<Feature> $feature */
if (! in_array($feature, $this->bootstrappedFeatures)) {
app($feature)->bootstrap();
$this->bootstrappedFeatures[] = $feature;
}
}
}
/**
* @return Builder<Tenant&Model>
*/

View file

@ -40,15 +40,6 @@ class TenancyServiceProvider extends ServiceProvider
// Make sure Tenancy is stateful.
$this->app->singleton(Tenancy::class);
// Make sure features are bootstrapped as soon as Tenancy is instantiated.
$this->app->extend(Tenancy::class, function (Tenancy $tenancy) {
foreach ($this->app['config']['tenancy.features'] ?? [] as $feature) {
$this->app[$feature]->bootstrap($tenancy);
}
return $tenancy;
});
// Make it possible to inject the current tenant by type hinting the Tenant contract.
$this->app->bind(Tenant::class, function ($app) {
return $app[Tenancy::class]->tenant;
@ -176,6 +167,11 @@ class TenancyServiceProvider extends ServiceProvider
return $instance;
});
// Bootstrap features that are already enabled in the config.
// If more features are enabled at runtime, this method may be called
// multiple times, it keeps track of which features have already been bootstrapped.
$this->app->make(Tenancy::class)->bootstrapFeatures();
Route::middlewareGroup('clone', []);
Route::middlewareGroup('universal', []);
Route::middlewareGroup('tenant', []);

View file

@ -36,7 +36,12 @@ if (! function_exists('tenant')) {
}
if (! function_exists('tenant_asset')) {
// todo@docblock
/**
* Generate a URL to an asset in tenant storage.
*
* If app.asset_url is set, this helper suffixes that URL before appending the asset path.
* If it is not set, the stancl.tenancy.asset route is used.
*/
function tenant_asset(string|null $asset): string
{
if ($assetUrl = config('app.asset_url')) {

View file

@ -18,8 +18,6 @@ beforeEach(function () {
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
});
// todo@move move these to be in the same file as the other tests from this PR (#909) rather than generic "action tests"
test('create storage symlinks action works', function() {
config([
'tenancy.bootstrappers' => [

View file

@ -115,8 +115,6 @@ test('files can get fetched using the storage url', function() {
test('storage_path helper does not change if suffix_storage_path is off', function() {
$originalStoragePath = storage_path();
// todo@tests https://github.com/tenancy-for-laravel/v4/pull/44#issue-2228530362
config([
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
'tenancy.filesystem.suffix_storage_path' => false,

View file

@ -27,6 +27,7 @@ use Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Database\Exceptions\TenantDatabaseDoesNotExistException;
use function Stancl\Tenancy\Tests\pest;
use Stancl\Tenancy\Events\MigratingDatabase;
beforeEach(function () {
if (file_exists($schemaPath = 'tests/Etc/tenant-schema-test.dump')) {
@ -95,6 +96,60 @@ test('migrate command works with tenants option', function () {
expect(Schema::hasTable('users'))->toBeTrue();
});
test('migrate command uses the passed database option as the template tenant connection', function () {
$originalTemplateConnection = config('tenancy.database.template_tenant_connection');
// Add a custom connection that will be used as the template for the tenant connection
// Identical to the default (mysql), just with different charset and collation
config(['database.connections.custom_connection' => [
"driver" => "mysql",
"url" => "",
"host" => "mysql",
"port" => "3306",
"database" => "main",
"username" => "root",
"password" => "password",
"unix_socket" => "",
"charset" => "latin1", // Different from the default (utf8mb4)
"collation" => "latin1_swedish_ci", // Different from the default (utf8mb4_unicode_ci)
"prefix" => "",
"prefix_indexes" => true,
"strict" => true,
"engine" => null,
"options" => []
]]);
$templateConnectionDuringMigration = null;
$tenantConnectionDuringMigration = null;
Event::listen(MigratingDatabase::class, function() use (&$templateConnectionDuringMigration, &$tenantConnectionDuringMigration) {
$templateConnectionDuringMigration = config('tenancy.database.template_tenant_connection');
$tenantConnectionDuringMigration = DB::connection('tenant')->getConfig();
});
// The original tenant template connection config remains default
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
Tenant::create();
// The original template connection is used when the --database option is not passed
pest()->artisan('tenants:migrate');
expect($templateConnectionDuringMigration)->toBe($originalTemplateConnection);
Tenant::create();
// The migrate command temporarily uses the connection passed in the --database option
pest()->artisan('tenants:migrate', ['--database' => 'custom_connection']);
expect($templateConnectionDuringMigration)->toBe('custom_connection');
// The tenant connection during migration actually used custom_connection's config
expect($tenantConnectionDuringMigration['charset'])->toBe('latin1');
expect($tenantConnectionDuringMigration['collation'])->toBe('latin1_swedish_ci');
// The tenant template connection config is restored to the original after migrating
expect(config('tenancy.database.template_tenant_connection'))->toBe($originalTemplateConnection);
});
test('migrate command only throws exceptions if skip-failing is not passed', function() {
Tenant::create();
@ -311,6 +366,21 @@ test('migrate fresh command works', function () {
expect(DB::table('users')->exists())->toBeFalse();
});
test('migrate fresh command respects force option in production', function () {
// Set environment to production
app()->detectEnvironment(fn() => 'production');
Tenant::create();
// Without --force in production, command should prompt for confirmation
pest()->artisan('tenants:migrate-fresh')
->expectsConfirmation('Are you sure you want to run this command?');
// With --force, command should succeed without prompting
pest()->artisan('tenants:migrate-fresh', ['--force' => true])
->assertSuccessful();
});
test('run command with array of tenants works', function () {
$tenantId1 = Tenant::create()->getTenantKey();
$tenantId2 = Tenant::create()->getTenantKey();

View file

@ -10,6 +10,7 @@ use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\Facades\Event;
use Stancl\Tenancy\Events\TenancyInitialized;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use Stancl\Tenancy\Actions\CloneRoutesAsTenant;
use Stancl\Tenancy\Middleware\InitializeTenancyByPath;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
@ -120,7 +121,7 @@ test('early identification works with path identification', function (bool $useK
RouteFacade::get('/{post}/comment/{comment}/edit', [$controller, 'computePost']);
});
$tenant = Tenant::create(['tenancy_db_name' => pest()->randomString()]);
$tenant = Tenant::create(['tenancy_db_name' => Str::random(10)]);
// Migrate users and comments tables on tenant connection
pest()->artisan('tenants:migrate', [

View file

@ -63,7 +63,7 @@ test('sqlite ATTACH statements can be blocked', function (bool $disallow) {
return json_encode(DB::select(request('q2')));
});
tenancy(); // trigger features: todo@samuel remove after feature refactor
tenancy()->bootstrapFeatures();
if ($disallow) {
expect(fn () => pest()->post('/central-sqli', [

View file

@ -12,6 +12,8 @@ test('tenant redirect macro replaces only the hostname', function () {
'tenancy.features' => [CrossDomainRedirect::class],
]);
tenancy()->bootstrapFeatures();
Route::get('/foobar', function () {
return 'Foo';
})->name('home');

View file

@ -11,16 +11,21 @@ use Stancl\Tenancy\Listeners\RevertToCentralContext;
use Stancl\Tenancy\Tests\Etc\Tenant;
use function Stancl\Tenancy\Tests\pest;
beforeEach(function () {
config([
'tenancy.features' => [TenantConfig::class],
'tenancy.bootstrappers' => [],
]);
tenancy()->bootstrapFeatures();
});
afterEach(function () {
TenantConfig::$storageToConfigMap = [];
});
test('nested tenant values are merged', function () {
expect(config('whitelabel.theme'))->toBeNull();
config([
'tenancy.features' => [TenantConfig::class],
'tenancy.bootstrappers' => [],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
@ -39,10 +44,6 @@ test('nested tenant values are merged', function () {
test('config is merged and removed', function () {
expect(config('services.paypal'))->toBe(null);
config([
'tenancy.features' => [TenantConfig::class],
'tenancy.bootstrappers' => [],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);
@ -68,10 +69,6 @@ test('config is merged and removed', function () {
test('the value can be set to multiple config keys', function () {
expect(config('services.paypal'))->toBe(null);
config([
'tenancy.features' => [TenantConfig::class],
'tenancy.bootstrappers' => [],
]);
Event::listen(TenancyInitialized::class, BootstrapTenancy::class);
Event::listen(TenancyEnded::class, RevertToCentralContext::class);

View file

@ -3,27 +3,42 @@
declare(strict_types=1);
use Illuminate\Foundation\Vite;
use Stancl\Tenancy\Tests\Etc\Tenant;
use Stancl\Tenancy\Overrides\Vite as StanclVite;
use Illuminate\Support\Facades\File;
use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper;
use Stancl\Tenancy\Features\ViteBundler;
use Stancl\Tenancy\Tests\Etc\Tenant;
test('vite helper uses our custom class', function() {
$vite = app(Vite::class);
expect($vite)->toBeInstanceOf(Vite::class);
expect($vite)->not()->toBeInstanceOf(StanclVite::class);
use function Stancl\Tenancy\Tests\withBootstrapping;
beforeEach(function () {
config([
'tenancy.features' => [ViteBundler::class],
'tenancy.filesystem.asset_helper_override' => true,
'tenancy.bootstrappers' => [FilesystemTenancyBootstrapper::class],
]);
$tenant = Tenant::create();
tenancy()->initialize($tenant);
app()->forgetInstance(Vite::class);
$vite = app(Vite::class);
expect($vite)->toBeInstanceOf(StanclVite::class);
File::ensureDirectoryExists(dirname($manifestPath = public_path('build/manifest.json')));
File::put($manifestPath, json_encode([
'foo' => [
'file' => 'assets/foo-AbC123.js',
'src' => 'js/foo.js',
],
]));
});
test('vite bundler ensures vite assets use global_asset when asset_helper_override is enabled', function () {
config(['tenancy.features' => [ViteBundler::class]]);
tenancy()->bootstrapFeatures();
withBootstrapping();
tenancy()->initialize(Tenant::create());
// Not what we want
expect(asset('foo'))->toBe(route('stancl.tenancy.asset', ['path' => 'foo']));
$viteAssetUrl = app(Vite::class)->asset('foo');
$expectedGlobalUrl = global_asset('build/assets/foo-AbC123.js');
expect($viteAssetUrl)->toBe($expectedGlobalUrl);
expect($viteAssetUrl)->toBe('http://localhost/build/assets/foo-AbC123.js');
});

View file

@ -8,6 +8,7 @@ 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;
@ -44,7 +45,7 @@ test('asset can be accessed using the url returned by the tenant asset helper',
$tenant = Tenant::create();
tenancy()->initialize($tenant);
$filename = 'testfile' . pest()->randomString(10);
$filename = 'testfile' . Str::random(8);
Storage::disk('public')->put($filename, 'bar');
$path = storage_path("app/public/$filename");
@ -136,7 +137,7 @@ test('TenantAssetController headers are configurable', function () {
tenancy()->initialize($tenant);
$tenant->createDomain('foo.localhost');
$filename = 'testfile' . pest()->randomString(10);
$filename = 'testfile' . Str::random(10);
Storage::disk('public')->put($filename, 'bar');
$this->withoutExceptionHandling();

View file

@ -29,6 +29,8 @@ use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQ
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledPostgreSQLDatabaseManager;
use Stancl\Tenancy\Database\TenantDatabaseManagers\PermissionControlledMicrosoftSQLServerDatabaseManager;
use function Stancl\Tenancy\Tests\pest;
use function Stancl\Tenancy\Tests\withBootstrapping;
use function Stancl\Tenancy\Tests\withTenantDatabases;
beforeEach(function () {
SQLiteDatabaseManager::$path = null;
@ -43,7 +45,7 @@ test('databases can be created and deleted', function ($driver, $databaseManager
"tenancy.database.managers.$driver" => $databaseManager,
]);
$name = 'db' . pest()->randomString();
$name = 'db' . Str::random(10);
$manager = app($databaseManager);
@ -70,7 +72,7 @@ test('dbs can be created when another driver is used for the central db', functi
return $event->tenant;
})->toListener());
$database = 'db' . pest()->randomString();
$database = 'db' . Str::random(10);
$mysqlmanager = app(MySQLDatabaseManager::class);
$mysqlmanager->setConnection('mysql');
@ -86,7 +88,7 @@ test('dbs can be created when another driver is used for the central db', functi
$postgresManager = app(PostgreSQLDatabaseManager::class);
$postgresManager->setConnection('pgsql');
$database = 'db' . pest()->randomString();
$database = 'db' . Str::random(10);
expect($postgresManager->databaseExists($database))->toBeFalse();
Tenant::create([
@ -146,18 +148,15 @@ test('db name is prefixed with db path when sqlite is used', function () {
expect(database_path('foodb'))->toBe(config('database.connections.tenant.database'));
});
test('sqlite databases use the WAL journal mode by default', function (bool|null $wal) {
$expected = $wal ? 'wal' : 'delete';
if ($wal !== null) {
SQLiteDatabaseManager::$WAL = $wal;
} else {
// default behavior
$expected = 'wal';
}
Event::listen(TenantCreated::class, JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {
return $event->tenant;
})->toListener());
test('sqlite databases respect the template journal_mode config', function (string $journal_mode) {
withTenantDatabases();
withBootstrapping();
config([
'database.connections.sqlite.journal_mode' => $journal_mode,
'tenancy.bootstrappers' => [
DatabaseTenancyBootstrapper::class,
],
]);
$tenant = Tenant::create([
'tenancy_db_connection' => 'sqlite',
@ -170,11 +169,18 @@ test('sqlite databases use the WAL journal mode by default', function (bool|null
$db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($expected);
// Before we connect to the DB using Laravel, it will be in default delete mode
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe('delete');
// cleanup
SQLiteDatabaseManager::$WAL = true;
})->with([true, false, null]);
// This will trigger the logic in Laravel's SQLiteConnector
$tenant->run(fn () => DB::select('select 1'));
$db = new PDO('sqlite:' . $dbPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Once we connect to the DB, it will be in the configured journal mode
expect($db->query('pragma journal_mode')->fetch(PDO::FETCH_ASSOC)['journal_mode'])->toBe($journal_mode);
})->with(['delete', 'wal']);
test('schema manager uses schema to separate tenant dbs', function () {
config([

View file

@ -42,6 +42,8 @@ beforeEach(function () {
],
]);
tenancy()->bootstrapFeatures();
Event::listen(
TenantCreated::class,
JobPipeline::make([CreateDatabase::class])->send(function (TenantCreated $event) {

View file

@ -236,11 +236,6 @@ abstract class TestCase extends \Orchestra\Testbench\TestCase
$app->singleton('Illuminate\Contracts\Console\Kernel', Etc\Console\ConsoleKernel::class);
}
public function randomString(int $length = 10)
{
return substr(str_shuffle(str_repeat($x = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', (int) (ceil($length / strlen($x))))), 1, $length);
}
public function assertArrayIsSubset($subset, $array, string $message = ''): void
{
parent::assertTrue(array_intersect($subset, $array) == $subset, $message);