Pengujian Otorisasi
Panel menyembunyikan hal-hal yang tidak boleh dilakukan pengguna. Test di halaman ini membuktikan bahwa mekanisme penyembunyian tersebut bukan mekanisme yang menegakkan keamanan. Otorisasi diperiksa di enam tempat — pintu masuk panel, page, resource policy, action, widget, dan relation manager — dan masing-masing menjawab pertanyaan berbeda dengan bentuk kegagalan berbeda. Halaman ini menjelaskan cara menguji semuanya di level route maupun class, serta mengapa pengujian route adalah bagian yang paling penting.
Contoh minimal yang berfungsi
<?php
declare(strict_types=1);
use App\Models\User;
it('lets an administrator in and refuses everybody else', function (): void {
$this->actingAs(User::factory()->create(['is_admin' => true]))
->get('/admin/users')
->assertOk();
$this->actingAs(User::factory()->create())
->get('/admin/users')
->assertForbidden();
$this->get('/admin/users')->assertRedirect('/login');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Tiga assertion menghasilkan tiga jawaban berbeda. Pengguna yang sudah login tetapi ditolak memperoleh 403; guest memperoleh redirect ke halaman login; record yang berada di luar scope resource menghasilkan 404. Memastikan status yang tepat merupakan bagian dari pengujian, bukan detail kosmetik.
Lapisan otorisasi
| Lapisan | Diperiksa oleh | Dideklarasikan sebagai | Bentuk penolakan |
|---|---|---|---|
| Pintu panel | middleware ResolvePanel | Panel::canAccess(Closure) dan PanelUser::canAccessPanel() | abort(403) sebelum page dibangun |
| Page | Page::render() | static canAccess(): bool | abort_unless(…, 403) |
| Resource | Resource::can*() → PolicyGate → Gate | Laravel policy | 403 dari controller |
| Resource scope | Resource::query() | override query(), atau tenancy | 404 — record dianggap tidak ada di sini |
| Action | Action::isAuthorizedFor() / isAuthorizedForEach() | authorize(), authorizeEachUsing() | tidak muncul di row; 403 pada endpoint |
| Widget | WidgetCollection | static canView(): bool | tidak dimasukkan ke widget page |
| Relation manager | RelationManager::can*() → PolicyGate | policy milik owner atau related model | 403 pada relation endpoint |
Lapisan-lapisan tersebut saling dikomposisikan, bukan saling menimpa. Pintu panel diperiksa lebih dulu, sehingga pengguna yang ditolak di sana tidak pernah mencapai resource policy. Ini penting diketahui ketika sebuah test tampak menguji policy tetapi sebenarnya lulus karena penolakan terjadi lebih awal.
Pintu panel
Ada dua pertanyaan, dan keduanya harus memberikan izin. Panel::isAccessibleTo():
if ($user instanceof PanelUser && ! $user->canAccessPanel($this)) {
return false;
}
return $this->canAccess === null || ($this->canAccess)($user);2
3
4
5
Panel yang menjawab "ya" tidak dapat mengabaikan user model yang menjawab "tidak". Uji kedua sisi tersebut:
use PandaPanel\Core\Panel;
it('refuses a user the panel itself does not admit', function (): void {
$this->actingAs(User::factory()->create())
->get('/admin')
->assertForbidden();
});
it('asks the panel predicate directly', function (): void {
$panel = panel('admin');
expect($panel->isAccessibleTo(User::factory()->create(['is_admin' => true])))->toBeTrue()
->and($panel->isAccessibleTo(User::factory()->create()))->toBeFalse()
->and($panel->isAccessibleTo(null))->toBeFalse();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pastikan juga middleware memang menjadi tempat pemeriksaan tersebut berlangsung. Route stack panel layak diuji setidaknya satu kali:
use Illuminate\Support\Facades\Route;
$middleware = Route::getRoutes()->getByName('panel.app.dashboard')?->gatherMiddleware() ?? [];
expect($middleware)->toContain('verified');2
3
4
5
Resource
Terdapat sepuluh static method. Semuanya mendelegasikan ke Resource::authorize(), lalu ke PolicyGate::allows():
| Method | Signature | Ability policy | Argumen |
|---|---|---|---|
canViewAny | static canViewAny(): bool | viewAny | class model |
canView | static canView(Model $record): bool | view | record |
canCreate | static canCreate(): bool | create | class model |
canEdit | static canEdit(Model $record): bool | update | record |
canDelete | static canDelete(Model $record): bool | delete | record |
canDeleteAny | static canDeleteAny(): bool | deleteAny | class model |
canRestore | static canRestore(Model $record): bool | restore | record |
canForceDelete | static canForceDelete(Model $record): bool | forceDelete | record |
canRestoreAny | static canRestoreAny(): bool | restoreAny | class model |
canForceDeleteAny | static canForceDeleteAny(): bool | forceDeleteAny | class model |
Perhatikan bahwa canEdit() meminta ability update. Kosakata panel dan nama method policy memang tidak identik. Test yang ditulis terhadap method policy bernama edit justru menguji sesuatu yang tidak pernah dipanggil.
use App\Panels\Admin\Resources\Users\UserResource;
it('delegates every resource ability to the policy', function (): void {
$admin = User::factory()->create(['is_admin' => true]);
$member = User::factory()->create();
$this->actingAs($admin);
expect(UserResource::canViewAny())->toBeTrue()
->and(UserResource::canCreate())->toBeTrue()
->and(UserResource::canView($member))->toBeTrue()
->and(UserResource::canEdit($member))->toBeTrue()
->and(UserResource::canDelete($member))->toBeTrue();
$this->actingAs($member);
expect(UserResource::canViewAny())->toBeFalse()
->and(UserResource::canCreate())->toBeFalse()
->and(UserResource::canDelete($admin))->toBeFalse();
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
viewAny dan view adalah dua pertanyaan berbeda. Menggunakan jawaban view untuk menggantikan viewAny dapat membuat seluruh akun terdaftar kepada semua pengguna:
it('refuses a member the index even though they may read themselves', function (): void {
$member = User::factory()->create();
$this->actingAs($member);
expect(UserResource::canView($member))->toBeTrue();
$this->get('/admin/users')->assertForbidden();
});2
3
4
5
6
7
8
9
Uji setiap route, termasuk write verb
Static method membuktikan policy terhubung dengan benar. Route membuktikan tidak ada jalur yang melewatinya. Resource baru layak memiliki seluruh rangkaian berikut:
$this->actingAs($member);
$this->get("/admin/users/{$other->id}")->assertForbidden();
$this->get("/admin/users/{$other->id}/edit")->assertForbidden();
$this->put("/admin/users/{$other->id}/edit", [
'name' => 'Renamed by a stranger',
'email' => $other->email,
])->assertForbidden();
$this->post('/admin/actions/record', [
'resource' => 'users', 'action' => 'delete', 'record' => $other->id,
])->assertForbidden();
$this->post('/admin/actions/cell', [
'resource' => 'users', 'column' => 'name', 'record' => $other->id, 'value' => 'x',
])->assertForbidden();
expect($other->fresh()->name)->not->toBe('Renamed by a stranger');2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
expect() terakhir bukan sekadar tambahan. Status code membuktikan request ditolak; database membuktikan tidak ada write yang tetap terjadi.
Action
Terdapat dua closure otorisasi yang diperiksa pada waktu berbeda, serta satu closure visibility:
| Method | Signature | Diperiksa kapan |
|---|---|---|
authorize | authorize(Closure $callback): static — Closure(?Model): bool | sebelum menjalankan action dan saat menserialisasi row |
authorizeEachUsing | authorizeEachUsing(Closure $callback): static — Closure(Model): bool | untuk setiap record bulk run, sebelum write apa pun |
visible | visible(Closure $callback): static — Closure(?Model): bool | hanya saat serialisasi; menyembunyikan tanpa otomatis melarang |
Action::toArray($record) mengembalikan null ketika closure visibility atau authorization menjawab tidak. Akibatnya action yang ditolak tidak muncul di row, bukan muncul sebagai tombol yang kemudian memberi 403. Helper menguji kondisi yang sama:
panelRecordActions(UserResource::class)
->assertExists('delete') // declared
->assertHidden('delete', $admin) // not offered on the admin's own row
->assertCanNotRun('delete', $admin);
panelTableActions(UserResource::class)->assertCanRun('purgeUnverified');2
3
4
5
6
assertCanNotRun() dan assertHidden() adalah dua assertion berbeda. visible() dapat menyembunyikan action tanpa berarti action tersebut terlarang, dan otorisasi tetap diperiksa ulang pada saat eksekusi. Karena itu, test yang hanya memastikan action tersembunyi belum membuktikan action tersebut tidak dapat dijalankan.
Untuk bulk action, sifat yang penting adalah all-or-nothing:
it('writes nothing when a selection contains one record the user may not touch', function (): void {
$this->post('/admin/actions/bulk', [
'resource' => 'users',
'action' => 'delete',
'records' => [$mine->id, $theirs->id],
]);
expect(User::find($mine->id))->not->toBeNull()
->and(User::find($theirs->id))->not->toBeNull();
});2
3
4
5
6
7
8
9
10
Lihat Pengujian action untuk seluruh helper yang tersedia.
Page
use Symfony\Component\HttpKernel\Exception\HttpException;
it('refuses a page the user cannot access', function (): void {
// The route, which is what a user actually hits.
$this->actingAs(User::factory()->create())
->get('/admin/settings')
->assertForbidden();
// And the page itself, independently of the panel around it.
expect(fn () => (new ForbiddenPage)->render())->toThrow(HttpException::class);
});2
3
4
5
6
7
8
9
10
11
Page::render() memanggil abort_unless(static::canAccess(), 403) sebelum membangun apa pun. Menyembunyikan page dari navigation adalah hal terpisah dan lebih lemah — route tetap ada dan tetap melakukan otorisasi:
expect(ForbiddenPage::canAccess())->toBeFalse()
->and(ForbiddenPage::slug())->toBe('restricted');2
Navigation juga layak diuji karena link yang terlihat tetapi berujung 403 adalah bug tersendiri:
use PandaPanel\Support\NavigationBuilder;
$labels = collect(app(NavigationBuilder::class)->for($panel, '/nav-host'))
->flatMap(fn (array $group): array => array_column($group['items'], 'label'))
->all();
expect($labels)->toContain('Settings')
->and($labels)->not->toContain('Restricted');2
3
4
5
6
7
8
Widget
Widget::canView(): bool secara default menghasilkan true; widget membatasi dirinya dengan override. WidgetCollection melakukan filter sebelum data di-resolve. Urutan ini lebih baik dibuktikan melalui test daripada diasumsikan:
use PandaPanel\Pages\WidgetCollection;
it('never resolves data for an unauthorized widget', function (): void {
// ForbiddenStatsWidget::stats() throws. Reaching it at all fails this test.
$collection = WidgetCollection::for([ForbiddenStatsWidget::class]);
expect($collection->definitions())->toBe([])
->and($collection->deferred())->toBeNull();
});
it('omits a widget the user may not view', function (): void {
$ids = array_column(
WidgetCollection::for([CountingStatsWidget::class, ForbiddenStatsWidget::class])->definitions(),
'id',
);
expect($ids)->toBe([CountingStatsWidget::id()]);
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Relation manager
Relation manager menggunakan beberapa ability yang tidak memiliki padanan method pada resource, tetapi semuanya tetap melewati PolicyGate yang sama:
| Method | Ability | Di-resolve dari |
|---|---|---|
canAttach(Model $owner) | attachAny | owner |
canDetach(Model $owner, Model $record) | detach | owner, dengan record sebagai argumen kedua |
canAssociate(Model $owner) | associateAny | owner |
canDissociate(Model $owner, Model $record) | dissociate | owner, dengan record |
canRestore(Model $owner, Model $record) | restore | record |
canForceDelete(Model $owner, Model $record) | forceDelete | record |
Endpoint juga menolak operasi yang tidak didukung bentuk relationship — misalnya attach pada one-to-many atau associate pada many-to-many. Penolakannya berupa 403, bukan schema error.
Strict authorization
Model tanpa policy menjawab "tidak" untuk seluruh ability. Ini aman, tetapi diam-diam — dan justru itulah masalahnya karena terlihat sama dengan permission yang memang sengaja ditolak. Panel::strictAuthorization() mengubah kondisi tersebut menjadi exception.
use PandaPanel\Core\Panel;
use PandaPanel\Exceptions\PanelAuthorizationException;
$panel = app(PanelManager::class)->register(
Panel::make('strict-host')
->path('strict-host')
->settings(false)
->strictAuthorization()
->resources([UnpolicedFixtureResource::class]),
);
app(PanelManager::class)->setCurrentPanel($panel);
expect(fn (): bool => UnpolicedFixtureResource::canViewAny())
->toThrow(PanelAuthorizationException::class);2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pemeriksaan berada di PolicyGate, bukan di Resource, sehingga seluruh ability panel tercakup — termasuk relation ability yang tidak memiliki method can*. Ada dua bentuk kegagalan dengan dua pesan berbeda:
PanelAuthorizationException::missingPolicy($model, $ability)— tidak ada policy yang terdaftar untuk model.PanelAuthorizationException::missingPolicyMethod($policy, $model, $ability)— policy ada tetapi tidak memiliki method tersebut dan juga tidak memilikibefore().
Policy dengan before() diterima untuk seluruh ability karena before memang dapat memberikan jawaban untuk semuanya.
Notice untuk policy yang hilang
Di luar strict mode, resource tanpa policy dapat hilang dari navigation tanpa penjelasan. MissingPolicyNotice mencatat alasannya satu kali:
| Method | Signature |
|---|---|
reportIfMissing | static reportIfMissing(string $resource, string $model): void |
forget | static forget(): void |
expectedPolicy | static expectedPolicy(string $model): string |
use Illuminate\Support\Facades\Log;
use PandaPanel\Support\MissingPolicyNotice;
beforeEach(fn () => MissingPolicyNotice::forget());
it('says why a resource left the navigation when it has no policy', function (): void {
Log::shouldReceive('debug')
->once()
->withArgs(fn (string $message): bool => str_contains($message, 'has no policy')
&& str_contains($message, 'make:policy')
&& str_contains($message, 'strictAuthorization'));
MissingPolicyNotice::reportIfMissing('App\\Resources\\OrderResource', UnpolicedModel::class);
});
it('suggests where the policy would live', function (): void {
expect(MissingPolicyNotice::expectedPolicy('App\\Models\\Order'))
->toBe('App\\Policies\\OrderPolicy');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Notice dicatat hanya sekali berapa pun kali navigation dibangun — itulah alasan forget() dipanggil di beforeEach. Jika policy ada dan menjawab "tidak", notice tidak dicatat karena itu merupakan keputusan otorisasi yang valid, bukan kesalahan konfigurasi.
Hal yang perlu diperhatikan
- 403, redirect, dan 404 memiliki arti berbeda. Sudah login tetapi ditolak adalah 403. Belum login adalah redirect. Record di luar scope resource adalah 404; mengharapkan 403 pada kasus terakhir justru berarti mengharapkan kebocoran informasi bahwa record tersebut ada.
- Status code hanya setengah dari test. Setelah setiap penolakan, pastikan tidak ada data yang berubah.
- Pintu panel melakukan short-circuit. Member yang ditolak di
/admintidak pernah mencapai users policy. Jika policy adalah subjek test, gunakan user yang memang diizinkan melewati panel door. canEdit()meminta abilityupdate. Route juga demikian. Method policy bernamaedittidak pernah dipanggil.canViewAny()menyembunyikan entry navigation; ia tidak mendaftarkan atau menghapus route. Route tetap ada dan melakukan otorisasi secara independen. Itulah sebabnya direct-URL test tetap penting.- Strict mode berlaku per panel.
PolicyGatemembacapanel()?->hasStrictAuthorization(), sehingga test yang mengharapkan exception harus memastikan panel strict sedang menjadi current panel.