# 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 ``` > **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 `', // judul berbahaya 'message' => 'bold', ]); $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('` 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 `