Files

1397 lines
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dokumentasi Whitebox Testing
**Proyek:** Laravel Application Security Audit
**Tanggal:** 16 Mei 2026
**Metode:** Whitebox Testing (Glass-box / Structural Testing)
**Framework:** Pest PHP v3 + PHPUnit v11, Laravel 13
**Total Test:** 371 test, 1.182 assertions — **semua LULUS (0 gagal)**
---
## Daftar Isi
1. [Pendahuluan](#1-pendahuluan)
2. [Strategi dan Metodologi](#2-strategi-dan-metodologi)
3. [Infrastruktur dan Konfigurasi Test](#3-infrastruktur-dan-konfigurasi-test)
4. [Unit Tests](#4-unit-tests)
5. [Feature Tests — Autentikasi](#5-feature-tests--autentikasi)
6. [Feature Tests — API](#6-feature-tests--api)
7. [Feature Tests — Middleware dan Keamanan](#7-feature-tests--middleware-dan-keamanan)
8. [Feature Tests — Access Control](#8-feature-tests--access-control)
9. [Feature Tests — Model dan Database](#9-feature-tests--model-dan-database)
10. [Feature Tests — Fitur Sistem](#10-feature-tests--fitur-sistem)
11. [Feature Tests — Services](#11-feature-tests--services)
12. [Feature Tests — Performa](#12-feature-tests--performa)
13. [Temuan Keamanan dan Perbaikan](#13-temuan-keamanan-dan-perbaikan)
14. [Statistik dan Cakupan](#14-statistik-dan-cakupan)
15. [Cara Menjalankan Test](#15-cara-menjalankan-test)
---
## 1. Pendahuluan
### 1.1 Apa itu Whitebox Testing?
Whitebox testing (disebut juga glass-box testing atau structural testing) adalah metode pengujian perangkat lunak di mana penguji memiliki akses penuh ke kode sumber, arsitektur internal, alur logika, dan struktur data aplikasi. Berbeda dengan blackbox testing yang hanya menguji perilaku dari luar, whitebox testing menguji **bagaimana** suatu sistem bekerja dari dalam.
Dalam konteks proyek ini, whitebox testing dilakukan dengan:
- Membaca seluruh kode sumber controller, middleware, model, dan service
- Mengidentifikasi setiap jalur eksekusi (code path) yang mungkin terjadi
- Menulis test case yang secara eksplisit menelusuri jalur-jalur tersebut
- Memverifikasi bahwa setiap kondisi batas (boundary condition) ditangani dengan benar
- Memastikan bahwa mekanisme keamanan aktif di setiap lapisan yang relevan
### 1.2 Tujuan dan Ruang Lingkup
Testing ini dilakukan dalam konteks **security audit 3 gelombang** yang telah dilakukan pada aplikasi. Tujuan spesifiknya adalah:
| Tujuan | Keterangan |
|--------|------------|
| Verifikasi Session Fixation | Memastikan session ID diregenerasi setelah setiap otentikasi |
| Verifikasi Password Policy | Memastikan riwayat, expiry, dan aturan kuat berjalan |
| Verifikasi Prunable Models | Memastikan data lama otomatis terhapus sesuai policy |
| Verifikasi Access Control | Memastikan setiap endpoint dilindungi oleh permission yang tepat |
| Verifikasi Rate Limiting | Memastikan brute-force dilindungi di semua endpoint sensitif |
| Verifikasi Security Headers | Memastikan header HTTP keamanan terpasang di semua respons |
| Verifikasi XSS Prevention | Memastikan output di-escape sebelum dikirim ke client |
| Verifikasi Cascade Integrity | Memastikan penghapusan data tidak menimbulkan orphan records |
### 1.3 Asumsi Dasar
- Seluruh test dijalankan dalam **lingkungan terisolasi** (Docker container, database PostgreSQL terpisah)
- Database di-refresh setiap test menggunakan `RefreshDatabase` trait
- Driver session, cache, dan queue semuanya menggunakan `array` (in-memory) agar test deterministik
- Test tidak menyentuh sistem produksi, file system nyata (kecuali `Storage::fake()`), atau layanan eksternal
---
## 2. Strategi dan Metodologi
### 2.1 Pendekatan Pengujian
Whitebox testing pada proyek ini mengikuti pendekatan berlapis:
```
┌─────────────────────────────────────────────────────┐
│ UNIT TESTS │
│ Menguji fungsi murni tanpa container Laravel │
│ (formatter, helper, caster, exception factory) │
├─────────────────────────────────────────────────────┤
│ FEATURE TESTS │
│ Menguji alur end-to-end dengan container penuh │
│ (HTTP request → middleware → controller → DB) │
└─────────────────────────────────────────────────────┘
```
### 2.2 Pola Test yang Digunakan
**Happy Path Testing** — jalur sukses normal:
```php
test('password update succeeds with valid current password', function () {
$user = User::factory()->create(['password' => Hash::make('current-pass')]);
$this->actingAs($user)->put('/password', [...valid data...])->assertRedirect();
expect(Hash::check('New-Pass-123', $user->fresh()->password))->toBeTrue();
});
```
**Negative Testing** — pengujian kondisi kegagalan yang diharapkan:
```php
test('wrong current password is rejected', function () {
// Memastikan sistem menolak password yang salah
$this->actingAs($user)->put('/password', ['current_password' => 'wrong'])
->assertSessionHasErrors();
});
```
**Boundary Testing** — pengujian nilai batas:
```php
test('upload rejects image exceeding 5 MB', function () {
$file = UploadedFile::fake()->create('big.jpg', 6000, 'image/jpeg'); // 6 MB
$this->actingAs($user)->postJson('/editor/upload', ['upload' => $file])->assertStatus(422);
});
```
**Security Path Testing** — pengujian jalur keamanan spesifik:
```php
test('web login regenerates the session id', function () {
$before = session()->getId();
$this->post('/login', [...]);
expect(session()->getId())->not->toBe($before); // harus berbeda
});
```
### 2.3 Isolasi Test
Setiap test menggunakan `RefreshDatabase` yang otomatis diterapkan melalui `tests/Pest.php`. Middleware yang bisa mengganggu test seperti `ThrottleRequests` dan `CheckLegalAgreement` dinonaktifkan secara global, kecuali di test yang memang menguji fitur tersebut.
---
## 3. Infrastruktur dan Konfigurasi Test
### 3.1 File Konfigurasi: `phpunit.xml`
```xml
<env name="APP_ENV" value="testing" force="true"/>
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false" force="true"/>
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
```
> **Catatan kritis:** Atribut `force="true"` wajib ada agar nilai ini menimpa konfigurasi di `.env` production yang menggunakan Redis. Tanpa `force="true"`, test akan mencoba konek ke Redis dan gagal karena hostname `redis` tidak dapat di-resolve di luar container Docker.
### 3.2 File Bootstrap: `tests/Pest.php`
```php
uses(TestCase::class, RefreshDatabase::class)
->in('Feature'); // Semua Feature test refresh DB
uses(TestCase::class)
->in('Unit'); // Unit test tidak butuh DB
expect()->extend('toBeOne', fn () => $this->toBe(1));
// Nonaktifkan throttle dan legal check secara global
\Illuminate\Support\Facades\Route::middlewareGroup('web', [...]);
```
Dua middleware dinonaktifkan global:
- `ThrottleRequests` — dinonaktifkan agar test tidak saling mengganggu; ada file test khusus yang mengaktifkan kembali
- `CheckLegalAgreement` — dinonaktifkan agar sebagian besar test tidak perlu setup persetujuan hukum
### 3.3 Struktur Direktori Test
```
tests/
├── Pest.php ← Bootstrap + trait global
├── TestCase.php ← Base class
├── Unit/
│ ├── ExampleTest.php
│ ├── Exceptions/
│ │ └── CustomExceptionsTest.php
│ ├── Helpers/
│ │ └── SessionHelperTest.php
│ ├── Monitoring/
│ │ └── MonitoringFormatterTest.php
│ ├── System/
│ │ └── ActivityFormatterTest.php
│ └── SystemConfig/
│ └── SettingValueCasterTest.php
└── Feature/
├── AccessControl/
│ ├── PermissionManagementTest.php
│ ├── RoleManagementTest.php
│ └── UserManagementTest.php
├── Api/
│ ├── ApiAuthExtendedTest.php
│ ├── AuthTest.php
│ ├── DeviceTokenTest.php
│ ├── HealthTest.php
│ └── OtpTest.php
├── Auth/
│ ├── AuthenticationTest.php
│ ├── EmailVerificationTest.php
│ ├── PasswordConfirmationTest.php
│ ├── PasswordControllerTest.php ← BARU (whitebox)
│ ├── PasswordResetTest.php
│ ├── PasswordUpdateTest.php
│ ├── RegistrationTest.php
│ ├── SessionFixationTest.php ← BARU (whitebox)
│ ├── SocialAuthTest.php
│ ├── TwoFactorTest.php
│ └── WebAuthnConfigTest.php
├── Database/
│ └── CascadeIntegrityTest.php
├── Helpers/
│ ├── ApiResponseTest.php
│ └── PasswordRuleHelperTest.php
├── Middleware/
│ ├── CheckActivePermissionTest.php
│ ├── CheckLegalAgreementTest.php
│ ├── IpAccessControlTest.php
│ ├── PasswordExpiryMiddlewareTest.php
│ └── SecurityHeadersTest.php
├── Models/
│ └── PrunableModelsTest.php ← BARU (whitebox)
├── Performance/
│ └── NPlusOneTest.php
├── Services/
│ ├── Auth/
│ │ └── PasswordPolicyServiceTest.php
│ ├── System/
│ │ └── BackupManagementServiceTest.php
│ └── SystemConfig/
│ └── SystemConfigServiceTest.php
├── System/
│ ├── AiCircuitBreakerTest.php
│ ├── EditorUploadTest.php ← BARU (whitebox)
│ ├── NotificationCenterTest.php ← BARU (whitebox)
│ └── SessionManagerTest.php ← BARU (whitebox)
├── ExampleTest.php
├── ImpersonateTest.php
├── MobileConfigTest.php
├── ProfileTest.php
└── RateLimitTest.php
```
---
## 4. Unit Tests
Unit tests menguji komponen secara terisolasi tanpa men-boot container Laravel. Semua fungsi yang diuji adalah **pure functions** atau kelas yang tidak bergantung pada database.
### 4.1 `SessionHelperTest.php` — Parser User-Agent
**File:** `tests/Unit/Helpers/SessionHelperTest.php`
**Target:** `app/Helpers/SessionHelper.php``parseUserAgent()`
Fungsi ini digunakan di Session Manager untuk menampilkan informasi perangkat pengguna. Pengujian memverifikasi akurasi deteksi sistem operasi dan browser.
| Test Case | Input | Expected Output |
|-----------|-------|-----------------|
| null user agent | `null` | `"Unknown"` |
| string kosong | `""` | `"Unknown"` |
| Deteksi Android | `"...Android..."` | Platform: Android |
| Deteksi iOS | `"...iPhone..."` | Platform: iOS |
| Deteksi Windows | `"...Windows NT..."` | Platform: Windows |
| Deteksi macOS | `"...Macintosh..."` | Platform: macOS |
| Deteksi Linux | `"...Linux..."` | Platform: Linux |
| Edge sebelum Chrome | `"...Edg/..."` | Browser: Edge (bukan Chrome) |
| Chrome | `"...Chrome/..."` | Browser: Chrome |
| Firefox | `"...Firefox/..."` | Browser: Firefox |
| Safari | `"...Safari/..." (tanpa Chrome)` | Browser: Safari |
| Browser tidak dikenal | `"BotAgent/1.0"` | Icon: `bi-globe` |
**Mengapa penting:** Deteksi Edge harus diperiksa **sebelum** Chrome karena user-agent Edge mengandung string "Chrome". Tanpa urutan yang benar, semua pengguna Edge akan terdeteksi sebagai Chrome.
### 4.2 `SettingValueCasterTest.php` — Casting Nilai Konfigurasi
**File:** `tests/Unit/SystemConfig/SettingValueCasterTest.php`
**Target:** `app/Services/SystemConfig/SettingValueCaster.php`
Menguji serialisasi dan deserialisasi nilai pengaturan sistem untuk berbagai tipe data.
**Tipe yang diuji:**
| Tipe | Input | Setelah `normalize()` | Setelah `serialize()` | Setelah `deserialize()` |
|------|----|---|---|---|
| `bool` | `"true"` | `true` | `"1"` | `true` |
| `int` | `"42"` | `42` | `"42"` | `42` |
| `float` | `"3.14"` | `3.14` | `"3.14"` | `3.14` |
| `json` | `'{"a":1}'` | `["a" => 1]` | `'{"a":1}'` | `["a" => 1]` |
| `string` | `" hello "` | `"hello"` | `"hello"` | `"hello"` |
| `image_path` | `/storage/img.png` | path | path | path |
Test `isUnchanged()` memverifikasi bahwa nilai yang identik dengan yang tersimpan tidak dianggap berubah, mencegah penulisan revision yang tidak perlu.
### 4.3 `ActivityFormatterTest.php` — Format Log Aktivitas
**File:** `tests/Unit/System/ActivityFormatterTest.php`
**Target:** `app/Services/System/ActivityFormatter.php`
Menguji logika format tampilan di audit log.
**Test `getFriendlyModelName()`:**
- `null``"System"`
- `"App\Models\User"``"User"`
- Class tidak dikenal → headline dari basename
**Test `getEventBadgeClass()`:**
| Event | CSS Class |
|-------|-----------|
| `created` | `text-bg-success` |
| `updated` | `text-bg-warning` |
| `deleted` | `text-bg-danger` |
| `restored` | `text-bg-info` |
| `login` | `text-bg-info` |
| `logout` | `text-bg-secondary` |
| `password_changed` | `text-bg-primary` |
| event tidak dikenal | `text-bg-theme-1` |
Test juga memverifikasi bahwa fungsi **case-insensitive** (input `"LOGIN"` sama dengan `"login"`).
**Test `formatChanges()`:**
- Field sensitif (`password`, `remember_token`) disembunyikan dari output
- Nilai boolean ditampilkan sebagai `"Ya"` / `"Tidak"` dalam bahasa Indonesia
- Array nested diubah menjadi string yang dapat dibaca manusia
### 4.4 `CustomExceptionsTest.php` — Custom Exception
**File:** `tests/Unit/Exceptions/CustomExceptionsTest.php`
**Target:** `app/Exceptions/`
Memverifikasi bahwa factory method pada custom exception menghasilkan pesan yang terstruktur dan jenis exception yang benar.
| Exception Class | Factory Methods yang Diuji |
|-----------------|---------------------------|
| `SystemConfigException` | `invalidKey()`, `saveFailed()`, `cacheFailed()` |
| `BackupOperationException` | `notFound()`, `restoreFailed()`, `createFailed()` |
| `MonitoringException` | `serviceUnavailable()`, `dataCollectionFailed()` |
Semua exception memverifikasi bahwa mereka adalah turunan `RuntimeException`.
### 4.5 `MonitoringFormatterTest.php` — Format Monitoring
**File:** `tests/Unit/Monitoring/MonitoringFormatterTest.php`
**Target:** `app/Services/Monitoring/MonitoringFormatter.php`
| Fungsi | Test Cases |
|--------|-----------|
| `bytes()` | B, KB, MB, GB, TB; presisi; nilai negatif di-clamp ke 0 |
| `duration()` | < 1 menit, format menit, jam+menit, hari+jam+menit; lewati komponen nol |
| `parseBytes()` | Round-trip unit penuh, string numerik, unit tidak dikenal, nilai fraksional |
---
## 5. Feature Tests — Autentikasi
### 5.1 `AuthenticationTest.php` — Login/Logout Dasar
**File:** `tests/Feature/Auth/AuthenticationTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Halaman login tersedia | HTTP 200, tampilan form login |
| Login berhasil dengan kredensial valid | Redirect ke dashboard, user terautentikasi |
| Login gagal dengan password salah | Session error, user tidak terautentikasi |
| Logout berhasil | Session dihancurkan, redirect ke login |
### 5.2 `PasswordControllerTest.php` — Update Password Web (BARU)
**File:** `tests/Feature/Auth/PasswordControllerTest.php`
**Target:** `app/Http/Controllers/Auth/PasswordController.php`
**Endpoint:** `PUT /password`
File ini ditulis khusus sebagai bagian dari whitebox testing untuk menguji semua jalur kode di `PasswordController::update()`.
#### Test 1: Happy Path
```
Input: current_password benar, password baru valid, konfirmasi cocok
Expected: Redirect ke /profile, tanpa error di session
Verifikasi: Hash::check(password_baru, user->fresh()->password) === true
```
#### Test 2: Stamping Timestamp
```
Setup: password_changed_at = null
Input: password update berhasil
Verifikasi: password_changed_at TIDAK NULL setelah update
```
Memastikan `PasswordPolicyService::recordPasswordChange()` dipanggil dan menyimpan timestamp.
#### Test 3: Validasi — Password Lama Salah
```
Input: current_password = 'wrong-pass'
Expected: Session error di bag 'updatePassword', field 'current_password'
Verifikasi: Password di database TIDAK berubah
```
#### Test 4: Validasi — Konfirmasi Tidak Cocok
```
Input: password = 'ABC', password_confirmation = 'XYZ'
Expected: Session errors (validasi gagal)
```
#### Test 5: Penolakan Riwayat Password
```
Setup: password_history_count = 3, user punya riwayat 'OldPassword1!'
Input: Mencoba menggunakan kembali 'OldPassword1!'
Expected: Session error (password sudah pernah digunakan)
```
#### Test 6: Bypass Riwayat Jika Dinonaktifkan
```
Setup: password_history_count = 0
Input: Menggunakan kembali password lama
Expected: Berhasil (history check di-skip)
```
Membuktikan bahwa pengecekan riwayat bersyarat, bukan selalu aktif.
#### Test 7: Pencatatan Riwayat
```
Setup: password_history_count = 5
Input: Password update berhasil
Verifikasi: PasswordHistory::where('user_id', $user->id)->count() === 1
```
#### Test 8: Password Lama Tidak Bisa Login
```
Input: Update password dari 'old-pass-456' ke 'Brand-New-888!'
Verifikasi:
Hash::check('old-pass-456', user->fresh()->password) === false
Hash::check('Brand-New-888!', user->fresh()->password) === true
```
Test ini menggantikan uji `logoutOtherDevices` yang lebih kompleks — fokus pada hasil fungsional yang paling penting: password lama benar-benar tidak valid.
#### Test 9: Guest Ditolak
```
Input: PUT /password tanpa autentikasi
Expected: Redirect ke /login (HTTP 302)
```
#### Test 10: Respons JSON
```
Input: putJson('/password', [...]) dengan credentials valid
Expected: HTTP 200, JSON body {success: true}
```
Membuktikan controller menangani JSON request dengan benar.
### 5.3 `SessionFixationTest.php` — Pencegahan Session Fixation (BARU)
**File:** `tests/Feature/Auth/SessionFixationTest.php`
Session fixation adalah serangan di mana penyerang menanamkan ID session sebelum korban login, lalu menggunakan ID yang sama setelah korban login untuk membajak session. Pencegahan dilakukan dengan **meregenerasi session ID** setelah setiap otentikasi.
#### Test 1: Login Web
```
Sebelum: session_id = "abc123"
Action: POST /login dengan kredensial valid
Setelah: session_id ≠ "abc123"
```
#### Test 2: Verifikasi 2FA
```
Setup: Session berisi 2fa_user_id, 2fa_code, 2fa_expires_at
Sebelum: session_id = X
Action: POST /2fa dengan code benar
Setelah: session_id ≠ X
```
Penting: tanpa regenerasi ini, penyerang yang mengetahui session pre-2FA bisa membypass 2FA.
#### Test 3: OAuth Callback (SocialAuth)
```
Setup: feature_google_oauth = true, session berisi social_auth_provider
Action: GET /auth/callback (Socialite di-mock mengembalikan user valid)
Verifikasi: session_id berubah
```
Perbaikan ini ditambahkan ke `SocialAuthController::callback()` selama security audit.
#### Test 4: Reset Password
```
Action: POST /reset-password dengan token valid
Verifikasi: session_id berubah setelah reset
```
#### Test 5: Mulai Impersonasi
```
Action: Admin memulai impersonasi user lain
Verifikasi: session_id berubah (session admin terisolasi dari session target)
```
#### Test 6: Berhenti Impersonasi
```
Action: POST /impersonate/stop
Verifikasi: session_id berubah (kembali ke konteks admin)
```
### 5.4 `SocialAuthTest.php` — OAuth Authentication
**File:** `tests/Feature/Auth/SocialAuthTest.php`
**Target:** `app/Http/Controllers/Auth/SocialAuthController.php`
| Test Case | Skenario Keamanan | Expected |
|-----------|------------------|---------|
| Provider dinonaktifkan | `feature_google_oauth = false` | 404 Not Found |
| Provider diaktifkan | `feature_google_oauth = true` | Redirect ke Google |
| Tanpa sesi provider | Tidak ada `social_auth_provider` di session | Redirect ke login + error |
| Email belum diverifikasi | `email_verified = false` dari provider | Ditolak, tidak login |
| User baru | Email belum ada di DB | User baru dibuat, diberi role User |
| Link email | User ada, belum punya google_id | `google_id` terhubung |
| **Takeover dicegah** | Email ada, google_id BERBEDA | **Ditolak** — identity existing user tidak bisa ditimpa |
| Re-use by provider ID | User sudah punya google_id yang cocok | Login langsung |
| Exception dari Socialite | `driver->user()` throw exception | Redirect ke login + error |
**Kasus kritis — Identity Takeover Prevention:**
```php
// Penyerang punya email yang sama dengan user lain, tapi google_id berbeda
$existing = User::factory()->create([
'email' => 'taken@example.com',
'google_id' => 'different-google-id', // sudah ada
]);
// Socialite mengembalikan: id='attacker-id', email='taken@example.com'
// Sistem HARUS menolak — tidak boleh menimpa google_id yang sudah ada
$this->get('/auth/callback')
->assertRedirect('/login')
->assertSessionHas('error');
expect($existing->fresh()->google_id)->toBe('different-google-id'); // tidak berubah
```
### 5.5 `TwoFactorTest.php` — Autentikasi Dua Faktor
**File:** `tests/Feature/Auth/TwoFactorTest.php`
**Target:** 2FA Controller
| Test Case | Verifikasi |
|-----------|-----------|
| View 2FA butuh session | Tanpa `auth.2fa_user_id` → redirect |
| Kode benar → login | User terautentikasi setelah kode cocok |
| Kode salah → ditolak | User tetap tidak terautentikasi |
| Kode kadaluarsa → redirect | Diarahkan kembali ke login |
| Kode terlalu pendek | Validasi menolak (< 6 digit) |
| Trust device: simpan | Row di `user_trusted_devices` terbuat |
| Trust device: tidak dipilih | Tidak ada row trusted device |
| Cookie device trusted | Skip 2FA, langsung login |
| Cookie device salah secret | 2FA tetap diminta |
### 5.6 `PasswordResetTest.php` — Reset Password via Email
**File:** `tests/Feature/Auth/PasswordResetTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Halaman request reset tersedia | HTTP 200 |
| Request token berhasil | Email dikirim (Notification::fake) |
| Reset dengan token valid | Password diubah, redirect ke login |
### 5.7 `RegistrationTest.php` — Registrasi User
**File:** `tests/Feature/Auth/RegistrationTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Halaman registrasi tersedia | HTTP 200 |
| Registrasi berhasil | User dibuat di DB, role `User` diberikan |
### 5.8 `WebAuthnConfigTest.php` — Konfigurasi WebAuthn
**File:** `tests/Feature/Auth/WebAuthnConfigTest.php`
Test struktural yang memverifikasi:
- Class controller WebAuthn login dan register ada
- Method yang dibutuhkan tersedia
- Setting `webauthn_enabled` dapat di-toggle
- Tabel `webauthn_credentials` ada dan memiliki kolom yang benar
---
## 6. Feature Tests — API
### 6.1 `AuthTest.php` — API Authentication Dasar
**File:** `tests/Feature/Api/AuthTest.php`
**Base URL:** `/api/v1/`
| Endpoint | Test Case | Kode Respons |
|----------|-----------|-------------|
| `POST /register` | Registrasi sukses | 201 Created |
| `POST /register` | Email duplikat | 422 Unprocessable |
| `POST /login` | Credentials valid | 200 OK + token |
| `POST /login` | Password salah | 422 Unprocessable |
| `POST /login` | User tidak aktif | 422 Unprocessable |
| `POST /logout` | Logout dengan token | 200 OK |
| `GET /profile` | Profil user sendiri | 200 OK + data user |
### 6.2 `ApiAuthExtendedTest.php` — API Auth Lanjutan (BARU)
**File:** `tests/Feature/Api/ApiAuthExtendedTest.php`
File ini ditulis sebagai bagian whitebox testing untuk menguji endpoint yang lebih kompleks dari API AuthController.
#### Grup: Delete Account (`DELETE /api/v1/profile/delete`)
| Test Case | Input | Expected |
|-----------|-------|---------|
| Hapus akun berhasil | password benar | 200, user terhapus dari DB |
| Password salah | password salah | 422, user tetap ada |
| Password tidak dikirim | body kosong | 422 Validation Error |
| Guest tidak bisa akses | tanpa token | 401 Unauthorized |
| Semua token ikut dicabut | delete akun | `user->tokens()->count() === 0` |
**Verifikasi pencabutan token:**
```php
$user->createToken('device-a');
$user->createToken('device-b');
$token = $user->createToken('device-c')->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->deleteJson('/api/v1/profile/delete', ['password' => 'secret']);
expect($user->tokens()->count())->toBe(0); // semua token dicabut
```
#### Grup: Update Password API (`POST /api/v1/profile/password`)
| Test Case | Verifikasi |
|-----------|-----------|
| Update berhasil | Hash::check(password_baru) === true |
| Current password salah | 422 |
| Reuse password dari riwayat | 422, body: `{status: "error"}` |
| Update berhasil → catat riwayat | `PasswordHistory::count() === 1` |
#### Grup: Register dengan Password Policy
| Test Case | Setting | Expected |
|-----------|---------|---------|
| Min length tidak terpenuhi | `password_min_length = 12`, kirim 5 char | 422 |
| Min length terpenuhi | `password_min_length = 6`, kirim 10 char | 201 |
| Harus ada angka | `password_require_numeric = true` | 422 jika tanpa angka |
#### Grup: Update Profil (`POST /api/v1/profile/update`)
| Test Case | Verifikasi |
|-----------|-----------|
| Update nama berhasil | `user->fresh()->name === 'Updated Name'` |
| Nama > 255 karakter | 422 Validation Error |
### 6.3 `OtpTest.php` — OTP Code
**File:** `tests/Feature/Api/OtpTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Kirim OTP (email valid) | Mail diantri, response 200 |
| Verifikasi OTP salah | 422 |
| Kode OTP < 6 digit | 422 Validation Error |
### 6.4 `HealthTest.php` — Health Check
**File:** `tests/Feature/Api/HealthTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| GET /api/v1/health | 200 OK, struktur respons valid |
| Respons berisi timestamp | `created_at` ada di body |
| Setiap check punya status | Array `checks` dengan field `status` |
### 6.5 `DeviceTokenTest.php` — Push Notification Token
**File:** `tests/Feature/Api/DeviceTokenTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Guest tidak bisa registrasi | 401 Unauthorized |
| User bisa registrasi token | 200, token tersimpan |
| Token duplikat → upsert | Tidak ada error, tidak ada duplikat |
| Unregister token | Token terhapus dari DB |
---
## 7. Feature Tests — Middleware dan Keamanan
### 7.1 `SecurityHeadersTest.php` — HTTP Security Headers
**File:** `tests/Feature/Middleware/SecurityHeadersTest.php`
Setiap request web harus menghasilkan header keamanan yang benar. Test ini membuat route khusus `/__sec-probe` dan memeriksa semua header respons.
| Header | Nilai yang Diharapkan | Tujuan |
|--------|-----------------------|--------|
| `X-Content-Type-Options` | `nosniff` | Mencegah MIME sniffing |
| `X-Frame-Options` | `SAMEORIGIN` | Mencegah clickjacking |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | Batasi referrer info |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Batasi akses API browser |
| `X-XSS-Protection` | tidak null | Perlindungan XSS browser lama |
| `Strict-Transport-Security` | **null** (over HTTP) | HSTS hanya aktif di HTTPS |
**Catatan penting:** HSTS **sengaja tidak dikirim** saat request melalui HTTP biasa (seperti dalam environment test). Ini adalah perilaku yang benar — HSTS di HTTP tidak ada gunanya dan bisa menyebabkan masalah jika dikonfigurasi salah.
### 7.2 `IpAccessControlTest.php` — Kontrol Akses IP
**File:** `tests/Feature/Middleware/IpAccessControlTest.php`
**Target:** `app/Http/Middleware/IpAccessControl.php`
| Test Case | Setting | IP Request | Expected |
|-----------|---------|-----------|---------|
| Tanpa aturan | - | 127.0.0.1 | 200 OK |
| IP di blacklist | `ip_blacklist = "127.0.0.1"` | 127.0.0.1 | 403 Forbidden |
| IP tidak di blacklist | `ip_blacklist = "10.0.0.5"` | 127.0.0.1 | 200 OK |
| Admin route, IP tidak di whitelist | `ip_whitelist_admin = "203.0.113.1"` | 127.0.0.1 | 403 Forbidden |
| Admin route, IP di whitelist | `ip_whitelist_admin = "127.0.0.1"` | 127.0.0.1 | 200 OK |
| Non-admin route, whitelist admin ada | `ip_whitelist_admin = "203.0.113.1"` | 127.0.0.1 | 200 OK |
| IP di auto-block cache | `ip_block:127.0.0.1` di cache | 127.0.0.1 | 429 |
| Single session: stale session | `session_single_session = true`, `last_session_id` berbeda | - | Redirect ke login |
**Detail test single session:**
```php
$user = User::factory()->create(['last_session_id' => 'OTHER_SESSION_ID']);
$this->actingAs($user)->get('/__ip-probe')
->assertRedirect(route('login', absolute: false));
$this->assertGuest(); // user sudah di-logout
```
### 7.3 `RateLimitTest.php` — Rate Limiting
**File:** `tests/Feature/RateLimitTest.php`
> **Catatan penting:** Middleware `ThrottleRequests` dinonaktifkan secara global di `Pest.php`. File ini **secara eksplisit mengaktifkan kembali** middleware tersebut dengan `$this->withMiddleware(ThrottleRequests::class)`.
| Endpoint | Test | Kondisi Block |
|----------|------|--------------|
| `POST /api/v1/login` | 15 percobaan | Salah satu harus menghasilkan 429 |
| `POST /api/v1/forgot-password` | 5 percobaan | Request ke-6 → 429 |
| `POST /api/v1/otp/send` | 5 percobaan | Request ke-6 → 429 |
| `POST /api/v1/register` | 5 percobaan | Request ke-6 → 429 |
| `POST /2fa` | 5 percobaan | Request ke-6 → 429 |
**Test isolasi IP:**
```php
// IP A sudah memenuhi batas
for ($i = 0; $i < 5; $i++) {
$this->call('POST', '/api/v1/forgot-password', server: ['REMOTE_ADDR' => '10.0.0.1']);
}
// IP B berbeda — tidak boleh kena blok
$r = $this->call('POST', '/api/v1/forgot-password', server: ['REMOTE_ADDR' => '10.0.0.2']);
expect($r->getStatusCode())->not->toBe(429);
```
### 7.4 `CheckActivePermissionTest.php` — Permission Aktif
**File:** `tests/Feature/Middleware/CheckActivePermissionTest.php`
Middleware ini memverifikasi bahwa permission yang dimiliki user masih dalam status `is_active = true`. Digunakan sebagai lapisan kontrol tambahan di luar Spatie Permission.
| Test Case | Status Permission | Expected |
|-----------|-----------------|---------|
| Permission tidak aktif | `is_active = false` | 403 Forbidden |
| Permission aktif | `is_active = true` | 200 OK |
| Permission tidak ada | - | 403 Forbidden |
| Status dicache | - | Hasil dicache, tidak query ulang |
### 7.5 `CheckLegalAgreementTest.php` — Persetujuan Hukum
**File:** `tests/Feature/Middleware/CheckLegalAgreementTest.php`
| Test Case | Kondisi | Expected |
|-----------|---------|---------|
| Guest | Tidak terautentikasi | Tidak terpengaruh (lewat) |
| User tanpa consent | Belum pernah setuju | Redirect ke halaman consent |
| User dengan consent terkini | Sudah setuju versi terbaru | 200 OK |
| User dengan consent lama | Versi policy sudah diupdate | Redirect ke consent baru |
### 7.6 `PasswordExpiryMiddlewareTest.php` — Kadaluarsa Password
**File:** `tests/Feature/Middleware/PasswordExpiryMiddlewareTest.php`
| Test Case | Kondisi | Expected |
|-----------|---------|---------|
| Password baru | `password_changed_at` baru | 200 OK, tidak redirect |
| Password kadaluarsa | `password_changed_at` > batas | Redirect ke halaman update password |
| Fitur dinonaktifkan | `password_expiry_days = 0` | Tidak pernah redirect |
| Guest | Tidak terautentikasi | Tidak terpengaruh |
---
## 8. Feature Tests — Access Control
### 8.1 `UserManagementTest.php` — Manajemen User
**File:** `tests/Feature/AccessControl/UserManagementTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Guest tidak bisa akses | Redirect ke login |
| User tanpa izin diblokir | 403 Forbidden |
| Buat user berhasil | User ada di DB dengan role |
| Password user baru divalidasi | Aturan kuat diterapkan |
| Update nama | Nama berubah |
| Update role | Role berubah |
| Update password saja | Hanya password yang berubah |
| Toggle status aktif | `is_active` berpindah true/false |
| Soft delete | User ditandai deleted_at |
| Force delete | User dihapus permanen |
| Force delete dengan referensi | Tidak bisa jika masih ada FK aktif (sesuai aturan bisnis) |
### 8.2 `RoleManagementTest.php` — Manajemen Role
**File:** `tests/Feature/AccessControl/RoleManagementTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Buat role + permissions | Role dan relasi tersimpan |
| Nama duplikat ditolak | 422 Validation Error |
| Permission invalid | 422 |
| Karakter tidak valid di nama | 422 |
| Update role | Nama dan permissions berubah |
| Toggle status | `is_active` berubah |
| Soft delete (ada user di role) | Ditolak dengan pesan error |
| Hard delete (tidak ada user) | Role dihapus |
| Restore soft-deleted | Role kembali aktif |
### 8.3 `PermissionManagementTest.php` — Manajemen Permission
**File:** `tests/Feature/AccessControl/PermissionManagementTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| Buat permission | Tersimpan di DB |
| Cross-guard support | Permission bisa untuk guard berbeda |
| Nama duplikat dalam guard | 422 |
| Guard tidak valid | 422 |
| Karakter tidak valid | 422 |
| Update nama | Nama berubah |
| Toggle status | `is_active` berubah |
| Soft delete | `deleted_at` diset |
---
## 9. Feature Tests — Model dan Database
### 9.1 `PrunableModelsTest.php` — Prunable Models (BARU)
**File:** `tests/Feature/Models/PrunableModelsTest.php`
Trait `MassPrunable` dari Laravel memungkinkan model mendefinisikan metode `prunable()` yang mengembalikan query untuk record yang harus dihapus saat `php artisan model:prune` dijalankan. Test ini memverifikasi bahwa setiap model memprune record yang tepat.
> **Catatan teknis:** File ini awalnya ada di `tests/Unit/Models/` tetapi dipindahkan ke `tests/Feature/Models/` karena query Eloquent membutuhkan database dan container Laravel penuh.
| Model | Tabel | Kriteria Prune | Diuji |
|-------|-------|----------------|-------|
| `OtpCode` | `otp_codes` | `expires_at < now()` | Record kadaluarsa dipilih, valid tidak dipilih |
| `UserTrustedDevice` | `user_trusted_devices` | `expires_at < now()` | Device expired dipilih |
| `PasswordHistory` | `password_histories` | `created_at < 365 hari lalu` | Record > 1 tahun dipilih |
| `MobileSyncLog` | `mobile_sync_logs` | `synced_at < 30 hari lalu` | Record > 30 hari dipilih |
| `MobileErrorLog` | `mobile_error_logs` | `occurred_at < 90 hari lalu` | Record > 90 hari dipilih |
| `Notification` | `system_notifications` | `created_at < 30 hari lalu` | Notifikasi lama dipilih |
| `AiHealingLog` | `ai_healing_logs` | `created_at < 90 hari lalu` | Log lama dipilih |
| `AiUsageLog` | `ai_usage_logs` | `created_at < 3 bulan lalu` | Log > 3 bulan dipilih |
**Pola test per model:**
```php
// 1. Insert record yang HARUS di-prune
// 2. Insert record yang TIDAK BOLEH di-prune
// 3. Panggil prunable() dan periksa hasil
test('MobileErrorLog prunable selects records older than 90 days', function () {
\DB::table('mobile_error_logs')->insert([
['message' => 'old error', 'occurred_at' => now()->subDays(100)], // HARUS dipilih
['message' => 'new error', 'occurred_at' => now()->subDays(5)], // TIDAK boleh
]);
$ids = (new MobileErrorLog)->prunable()->pluck('id');
expect($ids)->toHaveCount(1);
});
```
**Edge cases yang diuji:**
- `OtpCode`: query mengembalikan empty collection saat semua kode masih valid
- `UserTrustedDevice`: query mengembalikan empty saat tidak ada device expired
**Perbaikan kolom yang ditemukan selama whitebox testing:**
| Model | Kolom Salah (awal) | Kolom Benar |
|-------|-------------------|-------------|
| `OtpCode` | `email` | `identifier` |
| `MobileSyncLog` | `device_id`, `created_at`, `updated_at` | Hanya `synced_at` |
| `MobileErrorLog` | `level`, `created_at`, `updated_at` | `message`, `occurred_at` |
### 9.2 `CascadeIntegrityTest.php` — Integritas Cascade
**File:** `tests/Feature/Database/CascadeIntegrityTest.php`
Memverifikasi bahwa foreign key constraints dan cascade rules di database berfungsi dengan benar.
| Test Case | Aksi | Expected Cascade |
|-----------|------|-----------------|
| Force delete user | `$user->forceDelete()` | `password_histories` dihapus |
| Force delete user | `$user->forceDelete()` | `user_consents` dihapus |
| Force delete user | `$user->forceDelete()` | `user_trusted_devices` dihapus |
| Force delete user | `$user->forceDelete()` | `system_settings.created_by/updated_by` → null |
| Force delete user | `$user->forceDelete()` | `system_setting_revisions.changed_by` → null |
| Delete role | `$role->forceDelete()` | `role_has_permissions` dihapus |
| Delete permission | `$perm->forceDelete()` | `role_has_permissions` dihapus |
| Force delete user (audit role) | `$actor->forceDelete()` | `roles.created_by/updated_by` → null |
| Soft delete user | `$user->delete()` | Related rows TETAP ada (tidak cascade) |
**Mengapa penting:** Test ini membuktikan bahwa:
1. Hard delete tidak meninggalkan orphan records (data yatim)
2. Audit columns di-nullify ketika actor dihapus (tidak ada FK broken)
3. Soft delete **tidak** menghapus related records (intended behavior)
---
## 10. Feature Tests — Fitur Sistem
### 10.1 `SessionManagerTest.php` — Manajemen Session (BARU)
**File:** `tests/Feature/System/SessionManagerTest.php`
**Target:** `app/Http/Controllers/SystemSettings/SessionManagerController.php`
**Endpoint:** `/session-manager`
#### Kontrol Akses
| Test Case | Expected |
|-----------|---------|
| Guest GET /session-manager | Redirect ke /login |
| User tanpa izin | 403 Forbidden |
| User dengan `view active sessions` | 200 OK |
| Guest GET /session-manager/stats (JSON) | **401 Unauthorized** (bukan redirect) |
| User viewer GET /session-manager/stats | 200 OK + `{total, active}` |
> **Catatan:** JSON request dari guest mengembalikan 401, **bukan** 302. Ini adalah perilaku standar Laravel untuk request dengan header `Accept: application/json`.
#### Statistik Struktur
```php
test('stats endpoint returns valid json with total key', function () {
$this->actingAs(makeSessionViewer())
->getJson('/session-manager/stats')
->assertOk()
->assertJsonStructure(['total', 'active']);
});
```
#### Terminasi Session
| Test Case | Expected |
|-----------|---------|
| User tanpa `manage active sessions` | 403 Forbidden |
| Manager mencoba terminasi session sendiri | **403 Forbidden** (dikecualikan) |
| Manager terminasi session lain | 200 OK, `{success: true}` |
| Guest terminasi session | 401 Unauthorized |
**Test "tidak bisa terminasi session sendiri"** menggunakan teknik khusus:
```php
test('manager cannot terminate their own current session', function () {
$manager = makeSessionManager();
$this->actingAs($manager);
// Panggil controller langsung — HTTP request baru akan punya session ID berbeda
$controller = app(\App\Http\Controllers\SystemSettings\SessionManagerController::class);
$currentId = session()->getId();
$response = $controller->destroy(request(), $currentId);
expect($response->getStatusCode())->toBe(403);
expect(json_decode($response->getContent(), true)['success'])->toBeFalse();
});
```
> **Mengapa tidak via HTTP?** Karena driver session `array` membuat ID baru di setiap HTTP request test. Memanggil controller langsung via `app()` mempertahankan ID session yang sama sehingga kondisi "session sendiri" dapat diuji.
### 10.2 `EditorUploadTest.php` — Upload Gambar CKEditor (BARU)
**File:** `tests/Feature/System/EditorUploadTest.php`
**Target:** `app/Http/Controllers/SystemSettings/EditorUploadController.php`
**Endpoint:** `POST /editor/upload`
Setup: `Storage::fake('public')` digunakan agar file tidak benar-benar disimpan ke disk.
#### Kontrol Akses
| Test Case | Expected |
|-----------|---------|
| Guest upload | **401 Unauthorized** |
| User tanpa `manage global settings` | 403 Forbidden |
#### Validasi File
| Test Case | Input | Expected |
|-----------|-------|---------|
| Tanpa file | Body kosong | 400, `{error.message: "No file uploaded."}` |
| File PHP | `shell.php` (mime: application/x-php) | 422 |
| File SVG | `vector.svg` (mime: image/svg+xml) | 422 (SVG bisa mengandung script) |
| File JPEG valid | `photo.jpg` | 200, `{uploaded: 1, url, fileName}` |
| File PNG valid | `icon.png` | 200, `{uploaded: 1}` |
| File WebP valid | `modern.webp` | 200, `{uploaded: 1}` |
| File > 5 MB | 6000 KB JPEG | 422 |
> **Mengapa SVG ditolak?** SVG adalah format XML yang dapat mengandung script `<script>` tag dan event handler XSS. Menerima SVG dari pengguna membuka vektor serangan XSS yang signifikan.
#### Verifikasi Penyimpanan
```php
test('uploaded file is persisted to the public disk under editor/', function () {
$file = UploadedFile::fake()->image('stored.jpg');
$response = $this->actingAs($user)->post('/editor/upload', ['upload' => $file])->json();
// Ekstrak path relatif dengan benar menggunakan substr
$relativePath = substr((string) $response['url'], strlen('/storage/'));
Storage::disk('public')->assertExists($relativePath);
});
```
> **Catatan bug yang ditemukan:** Penggunaan `ltrim($url, '/storage/')` salah karena `ltrim` menghapus **karakter** satu per satu, bukan prefix string. Misalnya, `ltrim('/storage/editor/file.jpg', '/storage/')` menghasilkan `ditor/...` karena karakter 'e' juga ada di daftar karakter yang dihapus. Diperbaiki menggunakan `substr($url, strlen('/storage/'))`.
### 10.3 `NotificationCenterTest.php` — Pusat Notifikasi (BARU)
**File:** `tests/Feature/System/NotificationCenterTest.php`
**Target:** `NotificationCenterController.php`
**Endpoint:** `/notification-center`
#### Kontrol Akses
| Test Case | Expected |
|-----------|---------|
| Guest GET /notification-center | Redirect ke /login |
| User tanpa permission | 403 Forbidden |
| User dengan `view notification center` | 200 OK |
#### Broadcast Notifikasi (`POST /notification-center`)
| Test Case | Expected |
|-----------|---------|
| Viewer tanpa `manage notification center` | 403 Forbidden |
| Admin broadcast (judul, pesan, recipient, type) | 200, `{success: true}`, notif tersimpan di DB |
| Tipe notifikasi tidak valid (`xss-alert`) | 422 Validation Error |
| Tanpa judul dan pesan | 422 Validation Error |
#### Tanda Sudah Dibaca
| Test Case | Expected |
|-----------|---------|
| User tandai notifikasi sebagai dibaca | 200, `{success: true}` |
| Guest tandai sebagai dibaca (JSON request) | **401 Unauthorized** |
| Tandai semua sebagai dibaca | 200, `{success: true}` |
#### Hapus Personal (Hide)
```php
test('user can delete (hide) a notification from their view', function () {
$user = makeNotificationViewer();
$notification = makeNotificationForUser($user);
$this->actingAs($user)
->deleteJson(route('notification-center.destroy', $notification->id))
->assertOk()->assertJsonPath('success', true);
});
```
Hapus personal berarti menyembunyikan dari tampilan user tersebut (via `system_notification_user.deleted_at`), bukan menghapus notifikasi dari database.
#### Feature Flag
| Test Case | Setting | User | Expected |
|-----------|---------|------|---------|
| Flag dinonaktifkan | `feature_notification_center = false` | Viewer biasa | 403 Forbidden |
| Flag dinonaktifkan | `feature_notification_center = false` | User dengan `manage global settings` | **200 OK** (admin tetap bisa akses) |
#### Keamanan XSS di API Recent
```php
test('recent notifications content is escaped', function () {
Notification::create([
'title' => '<script>alert(1)</script>', // judul berbahaya
'message' => '<b>bold</b>',
]);
$response = $this->actingAs($user)->getJson('/notification-center/api/recent')->json();
$titles = collect($response['notifications'])->pluck('title')->all();
foreach ($titles as $t) {
expect($t)->not->toContain('<script>'); // harus di-escape
}
});
```
### 10.4 `AiCircuitBreakerTest.php` — AI Circuit Breaker
**File:** `tests/Feature/System/AiCircuitBreakerTest.php`
Menguji bahwa circuit breaker memblokir upaya AI self-healing yang berlebihan dalam satu siklus, mencegah loop healing yang tidak terkontrol.
```
Setup: Buat banyak AiHealingLog dalam waktu singkat
Action: Panggil mekanisme healing
Expected: Circuit breaker aktif dan memblokir request selanjutnya
```
### 10.5 `ImpersonateTest.php` — Impersonasi User
**File:** `tests/Feature/ImpersonateTest.php`
**Endpoint:** `POST /impersonate/{id}`, `POST /impersonate/stop`
| Test Case | Expected |
|-----------|---------|
| Guest mulai impersonasi | Redirect ke /login |
| User tanpa permission | 403 Forbidden |
| Impersonasi diri sendiri | 403 Forbidden |
| Impersonasi user tidak aktif | 403 Forbidden |
| Impersonasi Developer (Super Admin) | 403 Forbidden |
| Impersonasi berhasil | Auth berpindah ke target, session simpan `impersonator_id` |
| Impersonasi saat sudah impersonasi | Ditolak dengan error |
| Stop tanpa sesi impersonasi aktif | 403 Forbidden |
| Stop impersonasi | Auth kembali ke admin asli, `impersonator_id` dihapus |
---
## 11. Feature Tests — Services
### 11.1 `PasswordPolicyServiceTest.php` — Layanan Password Policy
**File:** `tests/Feature/Services/Auth/PasswordPolicyServiceTest.php`
**Target:** `app/Services/Auth/PasswordPolicyService.php`
Helper test lokal: `setSetting('key', value)` untuk mengatur konfigurasi tanpa boilerplate.
#### `isPasswordExpired()`
| Test Case | Kondisi | Expected |
|-----------|---------|---------|
| Expiry dinonaktifkan | `password_expiry_days = 0` | `false` (tidak pernah expired) |
| Ada `password_changed_at` baru | Changed 10 hari lalu, batas 30 hari | `false` |
| Ada `password_changed_at` lama | Changed 40 hari lalu, batas 30 hari | `true` |
| Tidak ada `password_changed_at` | Gunakan `created_at` sebagai fallback | Cek berdasarkan created_at |
#### `checkHistory()`
| Test Case | Kondisi | Expected |
|-----------|---------|---------|
| History count = 0 | Fitur dinonaktifkan | Tidak ada exception |
| Reuse password di riwayat | Password ada di 3 riwayat terbaru | Exception dilempar |
| Password berbeda | Password baru tidak ada di riwayat | Tidak ada exception |
| Hanya cek N terbaru | History count = 2, cek 3 entry | Hanya 2 terbaru yang dicek |
#### `recordPasswordChange()`
```php
test('recordPasswordChange creates history row and stamps password_changed_at', function () {
setSetting('password_history_count', 5); // harus > 0 untuk simpan history
$user = User::factory()->create(['password_changed_at' => null]);
PasswordPolicyService::recordPasswordChange($user, Hash::make('new-pass'));
expect($user->fresh()->password_changed_at)->not->toBeNull();
expect(PasswordHistory::where('user_id', $user->id)->count())->toBe(1);
});
```
> **Bug yang diperbaiki:** Test awal menggunakan `password_history_count = 0`, tetapi service dengan benar **tidak** menyimpan riwayat ketika nilai ini 0. Diperbaiki menjadi `= 5`.
#### `getRules()`
Memverifikasi bahwa objek `Password` yang dikembalikan mengandung aturan minimum dan maksimum dari setting sistem.
### 11.2 `SystemConfigServiceTest.php` — Layanan Konfigurasi Sistem
**File:** `tests/Feature/Services/SystemConfig/SystemConfigServiceTest.php`
| Kelompok Test | Yang Diverifikasi |
|---------------|------------------|
| `definitions()` | Mengembalikan array metadata setting lengkap |
| `all()` dan `get()` | Nilai default dari definisi, nilai dari DB override |
| `getPublicSettings()` | Hanya setting dengan `is_public = true` |
| `grouped()` | Setting dikelompokkan berdasarkan `group` |
| `update()` | Buat/timpa baris, tulis revision |
| Tracking revision | Setiap update menyimpan old_value dan new_value |
| Serialisasi bool | Boolean diserialisasi dengan benar |
| Cache clearing | Cache dibersihkan setelah update |
| Request tracking | IP address dan user agent disimpan di revision |
### 11.3 `BackupManagementServiceTest.php` — Layanan Backup
**File:** `tests/Feature/Services/System/BackupManagementServiceTest.php`
| Test Case | Verifikasi |
|-----------|-----------|
| `checkRequirements()` | Mengembalikan `{mysqldump: bool, php: bool}` |
| `testConnection()` pada local disk | Berhasil tanpa exception |
| `parseBytes()` (private) | Parsing string byte dengan unit |
| `formatBytes()` (private) | Format angka ke string berunit |
---
## 12. Feature Tests — Performa
### 12.1 `NPlusOneTest.php` — Pencegahan N+1 Query
**File:** `tests/Feature/Performance/NPlusOneTest.php`
N+1 query adalah masalah performa umum di ORM: satu query untuk mendapatkan daftar, lalu satu query **per baris** untuk mengambil relasi. Untuk 100 baris, ini menghasilkan 101 query.
#### Test Users Datatable
```
Setup baseline: 5 users dengan role masing-masing
Hitung query: X queries untuk /users?draw=1&length=10
Tambah 20 users lagi
Hitung query lagi: Y queries
Verifikasi: Y - X < 10 (jumlah query tidak melonjak drastis)
```
#### Test Roles Datatable
```
Buat 5 role, masing-masing dengan permission
Hitung query untuk /roles?draw=1&length=10
Verifikasi: total query < 20
```
#### Test Permissions Datatable
```
Buat 8 permission
Hitung query untuk /permissions?draw=1&length=10
Verifikasi: total query < 20
```
Ketiga test ini membuktikan bahwa eager loading (`with(['roles', 'permissions', 'creator'])`) sudah diterapkan dengan benar di DataTable query.
---
## 13. Temuan Keamanan dan Perbaikan
Berikut adalah temuan konkret yang diidentifikasi dan diperbaiki selama whitebox testing:
### 13.0 XSS di Modal Detail Notifikasi (KRITIS — ditemukan pasca-audit)
| Aspek | Detail |
|-------|--------|
| **Lokasi** | `resources/views/layouts/app.blade.php` baris ~22392250 |
| **Masalah** | Event handler klik `.notification-clickable` membaca `data-message` via jQuery `.data()`, yang otomatis men-decode HTML entity. Pesan lalu diproses regex markdown-lite (`**bold**``<strong>`) dan dimasukkan ke DOM via `.html(message)`. Penyerang yang bisa memasukkan notifikasi (memiliki permission `manage notification center`) bisa menulis payload `<script>alert(1)</script>` yang dieksekusi di browser semua penerima saat mereka mengklik kartu notifikasi. |
| **Risiko** | XSS stored — eksekusi script di browser user yang menerima notifikasi |
| **Perbaikan** | Menambahkan fungsi `escapeHtml()` di JS yang mengonversi `<`, `>`, `&`, `"`, `'` ke HTML entity **sebelum** regex markdown-lite dijalankan. Dengan ini `<script>` menjadi `&lt;script&gt;` dan ditampilkan sebagai teks. |
| **Perbaikan tambahan** | Menghapus field `message_html` (raw, tidak di-escape) dari respons JSON controller — field ini tidak digunakan di frontend namun berbahaya jika ada kode lain yang mengonsumsinya. |
| **Test** | `NotificationCenterTest.php::recent notifications content is escaped` — sudah lulus |
### 13.1 Session Fixation di OAuth Callback (KRITIS)
| Aspek | Detail |
|-------|--------|
| **Lokasi** | `app/Http/Controllers/Auth/SocialAuthController.php` |
| **Masalah** | `Auth::login()` dipanggil setelah OAuth callback tanpa `session()->regenerate()` |
| **Risiko** | Penyerang yang mengetahui session ID pre-login dapat membajak session pengguna setelah mereka login via Google/GitHub |
| **Perbaikan** | Menambahkan `session()->regenerate()` setelah `Auth::login()` di method `callback()` |
| **Test** | `SessionFixationTest.php::oauth callback regenerates the session id after successful login` |
### 13.2 XSS di Template AI Self-Healing (TINGGI)
| Aspek | Detail |
|-------|--------|
| **Lokasi** | `resources/views/pages/system_settings/ai-self-healing.blade.php` |
| **Masalah** | Penggunaan `{!! ... !!}` (raw output) dengan data dari database yang tidak di-escape |
| **Risiko** | Jika error message dari database mengandung script tag, akan dieksekusi di browser admin |
| **Perbaikan** | Diganti dengan `{{ }}` (escaped) dan `@if` Blade directive |
### 13.3 Kolom Tabel yang Salah di Test Prunable (BUG TEST)
| Model | Masalah | Perbaikan |
|-------|---------|-----------|
| `OtpCode` | Test menggunakan `email` tapi kolom adalah `identifier` | Ganti ke `identifier` |
| `MobileSyncLog` | Test menggunakan `device_id`, `created_at`, `updated_at` yang tidak ada | Hapus kolom yang tidak ada |
| `MobileErrorLog` | Test menggunakan `level`, `created_at`, `updated_at` yang tidak ada | Gunakan hanya `message` dan `occurred_at` |
### 13.4 Bug Path Prefix di Upload Test (BUG TEST)
| Aspek | Detail |
|-------|--------|
| **Masalah** | `ltrim('/storage/editor/file.jpg', '/storage/')` menghapus karakter, bukan prefix, menghasilkan `ditor/...` |
| **Perbaikan** | Ganti dengan `substr($url, strlen('/storage/'))` |
### 13.5 Setting Test yang Salah di PasswordPolicyService (BUG TEST)
| Aspek | Detail |
|-------|--------|
| **Masalah** | Test `recordPasswordChange` set `password_history_count = 0` lalu expect ada 1 baris history |
| **Penjelasan** | Service **benar** tidak menyimpan history ketika count = 0 |
| **Perbaikan** | Ubah setting ke `5` dalam test |
---
## 14. Statistik dan Cakupan
### 14.1 Ringkasan Eksekusi
```
Tests: 371 passed (1182 assertions)
Duration: ~48 detik
Hasil: 0 GAGAL, 0 ERROR
```
### 14.2 Distribusi Test per Kategori
| Kategori | File | Test (est.) |
|----------|------|-------------|
| Unit Tests | 5 | ~35 |
| Feature — Autentikasi | 10 | ~55 |
| Feature — API | 5 | ~25 |
| Feature — Middleware & Keamanan | 6 | ~35 |
| Feature — Access Control | 3 | ~30 |
| Feature — Model & Database | 2 | ~25 |
| Feature — Fitur Sistem | 6 | ~50 |
| Feature — Services | 3 | ~30 |
| Feature — Performa | 1 | ~5 |
| Feature — Lainnya | 7 | ~40 |
| **Total** | **49** | **~371** |
### 14.3 Test yang Ditambahkan Selama Whitebox Testing
| File | Keterangan |
|------|-----------|
| `tests/Feature/Auth/PasswordControllerTest.php` | 10 test baru untuk `PasswordController::update()` |
| `tests/Feature/Auth/SessionFixationTest.php` | 6 test session fixation di semua alur auth |
| `tests/Feature/Api/ApiAuthExtendedTest.php` | 14 test untuk endpoint API kompleks |
| `tests/Feature/System/SessionManagerTest.php` | 8 test untuk `SessionManagerController` |
| `tests/Feature/System/EditorUploadTest.php` | 13 test untuk `EditorUploadController` |
| `tests/Feature/System/NotificationCenterTest.php` | 15 test untuk `NotificationCenterController` |
| `tests/Feature/Models/PrunableModelsTest.php` | 17 test untuk 8 Prunable model |
**Total penambahan: 83 test baru**
### 14.4 Cakupan Jalur Kode Kritis
| Jalur Kode | Tercakup |
|-----------|---------|
| Semua endpoint yang memerlukan autentikasi | Ya — uji guest + user |
| Semua permission gate | Ya — uji dengan dan tanpa izin |
| Semua validasi input | Ya — uji happy path + invalid input |
| Semua kondisi session regeneration | Ya — 6 alur diuji |
| Semua prunable model | Ya — 8 model, 2 kondisi per model |
| Semua security header | Ya — 6 header diverifikasi |
| Rate limiting endpoint sensitif | Ya — 5 endpoint + isolasi IP |
| Cascade delete behavior | Ya — 9 skenario FK |
---
## 15. Cara Menjalankan Test
### 15.1 Persyaratan
- Docker + Docker Compose terinstall
- Container `project-laravel.test-1` berjalan (Laravel Sail)
- Database test (PostgreSQL) sudah di-migrate
### 15.2 Jalankan Semua Test
```bash
# Jalankan semua test di dalam container Docker
docker exec project-laravel.test-1 php artisan test --no-coverage
# Dengan output verbose (tampilkan setiap test)
docker exec project-laravel.test-1 php artisan test --no-coverage -v
```
### 15.3 Jalankan Test Spesifik
```bash
# File tertentu
docker exec project-laravel.test-1 php artisan test tests/Feature/Auth/SessionFixationTest.php --no-coverage
# Direktori tertentu
docker exec project-laravel.test-1 php artisan test tests/Feature/Auth/ --no-coverage
# Berdasarkan nama test (grep)
docker exec project-laravel.test-1 php artisan test --filter "session fixation" --no-coverage
```
### 15.4 Jalankan dengan Coverage Report
```bash
# Membutuhkan Xdebug atau PCOV
docker exec project-laravel.test-1 php artisan test --coverage --min=80
```
### 15.5 Menjalankan Hanya Unit atau Feature Tests
```bash
# Hanya Unit tests
docker exec project-laravel.test-1 php artisan test --testsuite=Unit
# Hanya Feature tests
docker exec project-laravel.test-1 php artisan test --testsuite=Feature
```
### 15.6 Troubleshooting
**Error: "redis: Name or service not known"**
> Pastikan `force="true"` ada di semua tag `<env>` di `phpunit.xml`. Tanpa ini, konfigurasi `.env` yang menggunakan Redis tidak tertimpa.
**Test gagal karena data tidak bersih antar test**
> Pastikan `RefreshDatabase` aktif. Cek `tests/Pest.php` bahwa `uses(TestCase::class, RefreshDatabase::class)->in('Feature')` sudah ada.
**Test session fixation selalu lulus karena ID tidak berubah**
> Driver session `array` membuat ID baru di setiap request HTTP. Jika test session fixation gagal, pastikan `actingAs()` digunakan sebelum mengambil `session()->getId()`.
**Test "terminate own session" menghasilkan ID berbeda**
> Gunakan direct controller invocation via `app(ControllerClass::class)` daripada HTTP request, agar session ID tidak berubah antar panggilan.
---
*Dokumentasi ini dibuat berdasarkan hasil whitebox testing yang dilakukan pada 16 Mei 2026. Semua 371 test lulus tanpa kegagalan.*