Pengujian Tenancy
Resource yang memiliki tenant scope dan dipanggil tanpa tenant akan melempar exception — ini adalah bagian dari desain, bukan gangguan — sehingga pengujian tenancy selalu dimulai dengan masuk ke tenant secara eksplisit. Assertion yang paling bernilai justru assertion negatif: buktikan tenant A tidak dapat melihat row milik tenant B. Test yang hanya memastikan A dapat melihat row milik A tetap akan lulus terhadap query yang sama sekali tidak memiliki scope.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
use PandaPanel\Tenancy\Tenancy;
it('shows one tenant\'s records and not the other\'s', function (): void {
$acme = Tenancy::for($this->acme, fn (): array => DocumentResource::query()
->pluck('title')
->all());
$beta = Tenancy::for($this->beta, fn (): array => DocumentResource::query()
->pluck('title')
->all());
expect($acme)->toBe(['Acme plan', 'Acme notes'])
->and($beta)->toBe(['Beta secrets']);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
DocumentResource::query() adalah query milik resource itu sendiri, dan seluruh read melewatinya — list, lookup record, action, hingga global search. Membuktikan scope di sana berarti sekaligus membuktikan semua jalur tersebut menggunakan scope yang sama.
Apa yang sedang diuji
Yang diuji adalah bagian multi-tenancy yang menjadi tanggung jawab framework, dan hanya bagian tersebut: tenant mana yang dituju request ini, apakah pengguna boleh berada di tenant tersebut, dan data apa yang boleh dilihat resource dengan scope. Framework tidak membuat database, mengganti connection, atau membaca subdomain. Tiga bagian berikut harus selaras:
| Bagian | Dideklarasikan oleh | Dampak jika tidak ada |
|---|---|---|
| Model tenant dan resolver | Panel::tenant(Workspace::class, fn (Request $r) => …) | panel tidak memiliki tenant scope dan ResolveTenant tidak pernah berjalan |
| Relationship menuju tenant | protected static ?string $tenantRelationship = 'workspace'; pada resource | resource dibiarkan tanpa scope |
| Membership pengguna | HasPanelTenants pada user model | pengguna dianggap tidak memiliki tenant dan panel menolaknya |
Arsitektur single-database adalah yang paling penting diuji di sini. Pada satu connection per tenant, boundary-nya adalah connection sehingga tidak ada where tenant yang dapat terlupakan. Pada satu database bersama, satu where yang hilang adalah kebocoran data.
Masuk ke tenant
PandaPanel\Tenancy\Tenancy adalah seluruh public surface untuk operasi ini. Semuanya static, sedangkan tenant disimpan di PanelContext, bukan static property, sehingga lifecycle-nya sama dengan request dan tidak bocor antar-test.
| Method | Signature | Mengembalikan |
|---|---|---|
bind | static bind(Model $tenant): void | — mengikat tenant untuk request ini |
current | static current(): ?Model | tenant yang sedang terikat, atau null |
require | static require(): Model | tenant, atau melempar PanelRegistrationException |
key | static key(): int|string|null | key tenant saat ini |
keyOf | static keyOf(Model $tenant): int|string | getTenantKey(), atau primary key sebagai fallback |
nameOf | static nameOf(Model $tenant): string | getTenantName(), atau atribut name, lalu key sebagai fallback |
describe | static describe(Model $tenant): array | ['key' => …, 'name' => …] |
availableTo | static availableTo(?Authenticatable $user, Panel $panel): array | list<Model> — tenant yang ditawarkan switcher |
allows | static allows(?Authenticatable $user, Model $tenant, Panel $panel): bool | apakah pengguna boleh masuk |
for | static for(Model $tenant, callable $callback): mixed | hasil callback, dengan tenant sebelumnya dipulihkan |
Tenancy::for()
Ini adalah method yang paling sering dibutuhkan dalam test. Ia melakukan bind, menjalankan callback, lalu memulihkan tenant sebelumnya di dalam finally:
use PandaPanel\Tenancy\Tenancy;
$titles = Tenancy::for($acme, fn (): array => DocumentResource::query()->pluck('title')->all());2
3
Bagian finally adalah inti dari perilakunya dan layak dibuktikan satu kali dalam suite yang banyak mengandalkannya:
it('restores the previous tenant even when the callback throws', function (): void {
Tenancy::bind($this->acme);
try {
Tenancy::for($this->beta, function (): void {
throw new RuntimeException('nope');
});
} catch (RuntimeException) {
// Expected. What matters is what is bound afterwards.
}
expect(Tenancy::current()?->getKey())->toBe($this->acme->getKey());
});
it('leaves nothing bound when it started with nothing', function (): void {
Tenancy::for($this->acme, fn () => null);
expect(Tenancy::current())->toBeNull();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Tenancy::bind() dan current()
Gunakan keduanya ketika test memerlukan tenant tetap terikat sepanjang beberapa statement, bukan hanya di dalam satu callback:
Tenancy::bind($this->acme);
expect(Tenancy::current()?->getKey())->toBe($this->acme->getKey())
->and(Tenancy::key())->toBe($this->acme->getKey());2
3
4
Di luar test, hanya ResolveTenant yang seharusnya memanggil bind(). Binding yang baru dibuat di tengah request berarti scope baru aktif setelah statement sebelumnya mungkin sudah melakukan query tanpa scope.
describe(), keyOf(), nameOf()
Inilah bentuk data yang diterima frontend beserta fallback yang digunakan di belakangnya:
// A tenant implementing PanelTenant answers for itself.
expect(Tenancy::describe($acme))->toBe(['key' => 1, 'name' => 'Acme']);
// One that does not falls back to the primary key and a `name` attribute,
// and to the key as a string when there is no name.
expect(Tenancy::nameOf($unnamed))->toBe('Acme');2
3
4
5
6
availableTo() dan allows()
Yang pertama menghasilkan daftar untuk switcher; yang kedua melakukan pemeriksaan per request. Keduanya sengaja dipisahkan — daftar untuk dropdown boleh diurutkan atau dipotong, sedangkan jawaban keamanan tidak boleh berubah karena keputusan tampilan. Uji keduanya:
expect(Tenancy::availableTo($user, $panel))->toHaveCount(1)
->and(Tenancy::allows($user, $acme, $panel))->toBeTrue()
->and(Tenancy::allows($user, $beta, $panel))->toBeFalse();2
3
User model yang tidak mengimplementasikan HasPanelTenants memperoleh daftar kosong dan false. Inilah yang membuat panel dengan tenant scope gagal secara tertutup, bukan terbuka dan menampilkan semua data.
Bentuk penolakan
Ada tiga jawaban berbeda dan masing-masing memiliki arti berbeda. Ketiganya layak diuji secara terpisah.
// Bound to a tenant this user does not belong to: 403. Hiding which tenants
// exist from somebody who already named one buys nothing.
$this->get('/tenancy-host/documents?workspace='.$beta->getKey())->assertForbidden();
// A tenant that is not there: 404.
$this->get('/tenancy-host/documents?workspace=999999')->assertNotFound();
// A user model that does not know about tenants at all: 403.
$this->actingAs(User::factory()->create())
->get('/tenancy-host/documents?workspace='.$acme->getKey())
->assertForbidden();2
3
4
5
6
7
8
9
10
11
Kemudian ada failure yang memang harus terdengar keras: resource dengan tenant scope tetapi tanpa tenant yang terikat:
use PandaPanel\Exceptions\PanelRegistrationException;
it('raises rather than running unscoped when no tenant is bound', function (): void {
expect(fn () => DocumentResource::query()->get())
->toThrow(PanelRegistrationException::class);
});2
3
4
5
6
Jika resource dibiarkan menjalankan query tanpa scope pada kondisi ini, hasilnya adalah seluruh record semua tenant dan halaman tampak seolah bekerja normal. Exception justru merupakan fitur keamanannya.
Relationship yang namanya dideklarasikan tetapi sebenarnya bukan relationship juga melempar exception dan menyebut resource, model, serta method yang bermasalah:
expect(fn () => Tenancy::for($acme, fn () => BrokenTenantResource::query()->get()))
->toThrow(PanelRegistrationException::class);2
Hal yang sengaja tidak diberi scope
Ada dua kondisi tanpa scope yang memang benar dan layak dikunci melalui test agar perubahan di masa depan tidak mencoba "memperbaikinya":
it('leaves a resource that names no tenant relationship unscoped', function (): void {
// A table every tenant reads the same way has nothing to scope by.
$names = Tenancy::for($acme, fn (): array => WorkspaceResource::query()->pluck('name')->all());
expect($names)->toBe(['Acme', 'Beta']);
});
it('scopes nothing in a panel that declared no tenancy', function (): void {
// Tenancy is a property of the panel, so a resource shared between a
// tenant panel and an admin one is scoped in the first and whole in the
// second.
app(PanelManager::class)->setCurrentPanel(null);
expect(DocumentResource::query()->count())->toBe(3);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
Shared prop dan switcher
Data yang diterima frontend adalah bagian dari kontrak. tenancy bernilai null pada panel yang tidak menggunakan tenancy — sengaja null, bukan object kosong, sehingga frontend cukup memeriksa tenancy === null dan switcher tidak pernah dirender ketika memang tidak ada tenant yang dapat dipilih.
use Inertia\Testing\AssertableInertia;
$this->get('/tenancy-host/documents?workspace='.$acme->getKey())
->assertInertia(fn (AssertableInertia $page) => $page
->where('tenancy.current.name', 'Acme')
->where('tenancy.current.key', (int) $acme->getKey())
->where('tenancy.available.0.name', 'Acme')
->where('tenancy.available.0.current', true)
->where('tenancy.available.1.current', false));
// A panel with no tenancy at all.
expect($this->get('/admin')->viewData('page')['props']['tenancy'])->toBeNull();2
3
4
5
6
7
8
9
10
11
12
Switcher hanya menawarkan tenant yang memang dimiliki pengguna karena dibangun dari daftar yang sama dengan yang digunakan pemeriksaan per-request. Dengan begitu switcher tidak menawarkan tujuan yang akhirnya menjawab 403:
$this->get('/tenancy-host/documents?workspace='.$acme->getKey())
->assertInertia(fn (AssertableInertia $page) => $page
->has('tenancy.available', 1)
->where('tenancy.available.0.name', 'Acme'));2
3
4
URL hanya dibuat jika panel telah menjelaskan caranya. Tanpa tenantUrlUsing(), getTenantUrl() mengembalikan null dan frontend menyembunyikan switcher, bukan menampilkan entry yang tidak menuju ke mana pun:
expect(Panel::make('urlless')->tenant(Workspace::class, fn () => null)->getTenantUrl($acme))
->toBeNull();2
Menyiapkan panel dengan tenant scope di dalam test
Cara identifikasi tenant adalah keputusan aplikasi, sehingga fixture boleh menggunakan mekanisme yang paling mudah dikendalikan. Query parameter cukup baik karena bagian yang sedang diuji adalah apa yang terjadi setelah tenant berhasil diidentifikasi:
use Illuminate\Http\Request;
use PandaPanel\Core\Panel;
use PandaPanel\Core\PanelManager;
use PandaPanel\Routing\PanelRouteRegistrar;
$panel = app(PanelManager::class)->register(
Panel::make('tenancy-host')
->path('tenancy-host')
->settings(false)
->tenant(
Workspace::class,
static fn (Request $request): ?Workspace => Workspace::query()->find($request->query('workspace')),
)
->tenantUrlUsing(static fn (Workspace $w, Panel $p): string => '/'.$p->getPath().'/documents?workspace='.$w->getKey())
->resources([DocumentResource::class]),
);
app(PanelRouteRegistrar::class)->register($panel);
Route::getRoutes()->refreshNameLookups();
app(PanelManager::class)->setCurrentPanel($panel);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
User model harus mengimplementasikan HasPanelTenants:
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use PandaPanel\Contracts\HasPanelTenants;
use PandaPanel\Core\Panel;
final class TenantUser extends User implements HasPanelTenants
{
public function getPanelTenants(Panel $panel): Collection
{
return $this->workspaces()->orderBy('id')->get();
}
public function canAccessPanelTenant(Model $tenant, Panel $panel): bool
{
return $this->workspaces()->whereKey($tenant->getKey())->exists();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Perhatikan juga panel door. User model pada aplikasi contoh memiliki canAccessPanel() yang membaca email_verified_at, dan 403 dari panel door terlihat sama seperti 403 dari tenancy. Pastikan fixture user sudah diverifikasi, atau test sedang membuktikan jenis penolakan yang berbeda dari yang Anda maksud.
Pekerjaan console dan queue
Apa pun yang secara sah harus melintasi tenant boundary — command yang melakukan loop tenant atau job yang masuk kembali ke tenant tempat job tersebut di-dispatch — masuk melalui Tenancy::for(). Inilah bentuk yang perlu diuji:
it('re-enters the tenant the job was queued from', function (): void {
$counts = [];
foreach (Workspace::all() as $workspace) {
$counts[$workspace->name] = Tenancy::for(
$workspace,
fn (): int => DocumentResource::query()->count(),
);
}
expect($counts)->toBe(['Acme' => 2, 'Beta' => 1]);
});2
3
4
5
6
7
8
9
10
11
12
Hal yang perlu diperhatikan
Tenancy::for()melakukan bind, bukan otorisasi. Method ini adalah mekanisme yang digunakanResolveTenantsetelah pemeriksaan akses, sehingga test yang memanggilnya langsung memang sengaja melewati membership check. GunakanTenancy::allows()atau request HTTP jika otorisasi adalah hal yang sedang diuji.- Panel context terlebih dahulu. Scoping memeriksa
panel()sebelum hal lain. Jika tidak ada panel terikat, scope dilewati. Itu jawaban yang benar untuk pertanyaan yang berbeda dari yang biasanya ingin Anda uji. - 403 dan 404 tidak dapat dipertukarkan. Tenant yang ada tetapi bukan milik pengguna adalah 403; tenant yang tidak ada adalah 404. Assert status yang memang Anda maksud.
- Positive test hampir tidak bernilai jika sendirian.
expect($acme)->toBe(['Acme plan', 'Acme notes'])tetap dapat lulus terhadap query tanpa scope apabila fixture kebetulan hanya berisi data Acme. Selalu pastikan row tenant lain tidak ikut muncul. - Resource tanpa
$tenantRelationshipdibiarkan unscoped secara diam-diam. Ini perilaku yang benar sekaligus kesalahan konfigurasi yang sangat mudah dilakukan. Uji scope untuk setiap resource yang memang seharusnya terikat tenant; jangan berasumsi panel otomatis memberi scope pada semuanya.