diff --git a/.gitignore b/.gitignore index 613a645e4d..5f90007354 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,7 @@ mcp.json .env.testing nul -graphify-out \ No newline at end of file + +# Agent +/.qwen +graphify-out diff --git a/app/Http/Controllers/Setting/PengaturanDatabaseController.php b/app/Http/Controllers/Setting/PengaturanDatabaseController.php index 230d676978..1969459067 100644 --- a/app/Http/Controllers/Setting/PengaturanDatabaseController.php +++ b/app/Http/Controllers/Setting/PengaturanDatabaseController.php @@ -15,7 +15,6 @@ use Illuminate\Support\Facades\File; use Exception; -use App\Http\Controllers\Setting\ZipArchive; class PengaturanDatabaseController extends Controller @@ -72,16 +71,28 @@ public function createBackup() { try { Log::info('Starting backup process.'); - - Artisan::call('backup:run'); - - Log::info('Backup command output: ' . Artisan::output()); + + $exitCode = Artisan::call('backup:run'); + $output = Artisan::output(); + + Log::info('Backup command output: ' . $output); + Log::info('Backup exit code: ' . $exitCode); + + if ($exitCode !== 0) { + Log::error('Backup process failed with exit code: ' . $exitCode); + + return response()->json([ + 'success' => false, + 'message' => 'Backup gagal. Periksa log aplikasi untuk detail.', + ], 500); + } + Log::info('Ending backup process.'); - + return response()->json(['success' => true, 'message' => 'Backup completed successfully']); } catch (\Exception $e) { Log::error('Backup process failed: ' . $e->getMessage(), ['exception' => $e]); - + return response()->json(['success' => false, 'message' => 'Backup process failed', 'error' => $e->getMessage()], 500); } } @@ -148,55 +159,69 @@ public function restoreBackup(Request $request) 'backupFile' => 'required|file', ]); - // Validasi tipe file - $allowedExtensions = ['sql']; $file = $request->file('backupFile'); - - // Validate file extension first using the original method - $extension = $file->getClientOriginalExtension(); - if (!in_array($extension, $allowedExtensions)) { - return response()->json(['message' => 'File harus berupa .sql'], 422); - } - - // Use FileUploadService for secure file upload + $extension = strtolower($file->getClientOriginalExtension()); + + // Fix #4: Hanya terima .zip dari backup system — .sql tidak lagi didukung + if ($extension !== 'zip') { + return response()->json([ + 'success' => false, + 'message' => 'Hanya file .zip dari backup system yang diizinkan untuk restore.', + ], 422); + } + $fileUploadService = new \App\Services\FileUploadService(); - - // Define allowed MIME types for sql files - $allowedMimes = ['application/octet-stream', 'text/plain', 'application/sql']; - - // Upload file securely to temp directory - $path = $fileUploadService->uploadSecure($file, 'backup-temp', $allowedMimes, 102400); // 100MB max - + $allowedMimes = \App\Services\FileUploadService::getAllowedMimes('archive'); + $maxSize = 512000; // 500MB + + $path = $fileUploadService->uploadSecure($file, 'backup-temp', $allowedMimes, $maxSize); + $filename = basename($path); - $allowedChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-'; - if (!preg_match("/^[" . $allowedChars . "]+$/", $filename)) { - return response()->json(['message' => 'Nama file tidak valid, Hanya boleh mengandung huruf (a-z/A-Z), angka (0-9), titik (.), dan garis bawah (_)'], 422); + if (! preg_match("/^[" . $allowedChars . "]+$/", $filename)) { + return response()->json([ + 'success' => false, + 'message' => 'Nama file tidak valid. Hanya boleh mengandung huruf (a-z/A-Z), angka (0-9), titik (.), dan garis bawah (_).', + ], 422); } - // Simpan file ke direktori sementara - $file = $request->file('backupFile'); $setDir = 'backup-temp'; - // The path is already handled by the FileUploadService above, so we don't need this line anymore - // The path variable is already set from the uploadSecure method - + try { Log::info('Starting restore process.'); - $finalPath = Storage::path($path); - Log::info("Normalized SQL file path from upload: $finalPath"); + // Fix #3: Pre-restore backup otomatis (best-effort, tidak memblokir restore jika gagal) + try { + Log::info('Menjalankan pre-restore backup otomatis...'); + $backupExit = Artisan::call('backup:run'); + if ($backupExit !== 0) { + Log::warning('Pre-restore backup gagal (exit code: ' . $backupExit . '). Restore tetap dilanjutkan.'); + } else { + Log::info('Pre-restore backup berhasil.'); + } + } catch (\Exception $backupEx) { + Log::warning('Pre-restore backup exception: ' . $backupEx->getMessage() . '. Restore tetap dilanjutkan.'); + } + + $finalPath = Storage::disk('public')->path($path); - // Jalankan proses restore - $this->runRestoreDatabase($finalPath); + Log::info('Restore from ZIP: ' . $finalPath); + $result = $this->restoreFromZip($finalPath); - return response()->json(['message' => 'Database berhasil direstore.'], 200); + return response()->json([ + 'success' => true, + 'message' => "Restore berhasil. Database dan {$result['files_restored']} file asset telah dipulihkan.", + ], 200); } catch (\Exception $e) { Log::error('Restore error: ' . $e->getMessage()); - return response()->json(['message' => $e->getMessage()], 500); + + return response()->json([ + 'success' => false, + 'message' => $e->getMessage(), + ], 500); } finally { - // Hapus file sementara - $this->deleteTemporaryDirectory(Storage::path($setDir)); + $this->deleteTemporaryDirectory(Storage::disk('public')->path($setDir)); Log::info('Direktori sementara berhasil dihapus.'); } } @@ -210,19 +235,24 @@ private function runRestoreDatabase($sqlFilePath) $dbUser = config('database.connections.mysql.username'); $dbPass = config('database.connections.mysql.password'); - // Buat command untuk restore database + // Gunakan path binary dari konfigurasi (sama seperti spatie untuk mysqldump) + $binaryPath = config('database.connections.mysql.dump.dump_binary_path', ''); + $mysqlBinary = $binaryPath + ? rtrim($binaryPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'mysql' + : 'mysql'; + + // Fix #1: Tambahkan escapeshellarg pada $sqlFilePath untuk mencegah shell injection $command = sprintf( - 'mysql -h%s -P%s -u%s %s %s < "%s"', + '%s -h%s -P%s -u%s %s %s < %s 2>&1', + escapeshellarg($mysqlBinary), escapeshellarg($dbHost), escapeshellarg($dbPort), escapeshellarg($dbUser), - $dbPass !== '' ? '-p' . escapeshellarg($dbPass) : '', + $dbPass !== '' ? '--password=' . escapeshellarg($dbPass) : '', escapeshellarg($dbName), - $sqlFilePath + escapeshellarg($sqlFilePath) ); - // $this->info("Executing command: $command"); - exec($command, $output, $returnVar); // cek hasil @@ -234,9 +264,182 @@ private function runRestoreDatabase($sqlFilePath) Log::info('Database restored successfully.'); } + /** + * Restore file asset dan database dari file ZIP backup (spatie/laravel-backup). + */ + private function restoreFromZip($zipFilePath) + { + $zip = new \ZipArchive(); + + if ($zip->open($zipFilePath) !== true) { + throw new \Exception('Gagal membuka file ZIP. Pastikan file tidak rusak.'); + } + + $sqlDumpPath = null; + $filesRestored = 0; + $storageBase = storage_path('app'); + $tempBase = storage_path('app/public/backup-temp'); + $tempSqlPath = $tempBase . '/restore-dump.sql'; + + try { + // Fix #6: Hitung jumlah db-dump entries sebelum proses — tolak jika > 1 + $dbDumpCount = 0; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = str_replace('\\', '/', $zip->getNameIndex($i)); + if (str_starts_with($name, 'db-dumps/') && ! str_ends_with($name, '/')) { + $dbDumpCount++; + } + } + if ($dbDumpCount > 1) { + throw new \Exception("File ZIP mengandung {$dbDumpCount} database dump. Hanya 1 yang diizinkan."); + } + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + + // Skip directories (entries ending with /) + if (str_ends_with($entry, '/')) { + continue; + } + + // Normalize backslash to forward slash (cross-platform ZIP entries) + $normalized = str_replace('\\', '/', $entry); + + // Database dump — ekstrak ke file terpisah untuk restore via mysql + if (str_starts_with($normalized, 'db-dumps/')) { + // Fix #2: Validasi path traversal dan subdirektori sebelum extractTo() + $relativeToDbDumps = substr($normalized, strlen('db-dumps/')); + if (str_contains($relativeToDbDumps, '..') || str_contains($relativeToDbDumps, '/')) { + Log::warning('Skipping db-dumps entry with traversal or subdirectory: ' . $entry); + continue; + } + + $zip->extractTo($tempBase, $entry); + $extractedPath = $tempBase . '/' . $entry; + if (file_exists($extractedPath)) { + rename($extractedPath, $tempSqlPath); + $sqlDumpPath = $tempSqlPath; + } + $dbDumpsDir = $tempBase . '/db-dumps'; + if (is_dir($dbDumpsDir)) { + $this->deleteTemporaryDirectory($dbDumpsDir); + } + continue; + } + + // File storage — map ke path relatif di dalam storage/app/ + $relativePath = $this->mapZipEntryToStoragePath($normalized); + if ($relativePath === null) { + continue; + } + + // Build target path absolut + $targetPath = $storageBase . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + $targetDir = dirname($targetPath); + + // Validasi: target harus berada di dalam storage/app/ + $normalizedTargetDir = str_replace('\\', '/', $targetDir); + $normalizedStorageBase = str_replace('\\', '/', $storageBase); + if (! str_starts_with($normalizedTargetDir, $normalizedStorageBase)) { + Log::warning('Skipping entry outside storage: ' . $entry); + continue; + } + + // Buat direktori jika belum ada + if (! is_dir($targetDir)) { + mkdir($targetDir, 0755, true); + } + + // Ekstrak file individual + $content = $zip->getFromIndex($i); + if ($content !== false) { + file_put_contents($targetPath, $content); + $filesRestored++; + } else { + Log::warning('Failed to extract from ZIP: ' . $entry); + } + } + + // Restore database dari SQL dump yang ditemukan di ZIP + if ($sqlDumpPath && file_exists($sqlDumpPath)) { + Log::info('Restoring database from ZIP dump: ' . $sqlDumpPath); + $this->runRestoreDatabase($sqlDumpPath); + } else { + Log::warning('No database dump found in ZIP — only file assets restored'); + } + + Log::info('Restore from ZIP completed: ' . $filesRestored . ' files restored'); + + return ['files_restored' => $filesRestored]; + } finally { + $zip->close(); + if (file_exists($tempSqlPath)) { + unlink($tempSqlPath); + } + } + } + + /** + * Map ZIP entry ke path relatif di dalam storage/app/. + * Menangani baik path absolute (backup lama, relative_path=null) maupun + * path relative (backup baru, relative_path=base_path()). + */ + private function mapZipEntryToStoragePath($entry) + { + $skipDirs = ['backup-storage/', 'backup-temp/', 'framework/', 'logs/', 'debugbar/']; + + $marker = 'storage/app/'; + $pos = strpos($entry, $marker); + + if ($pos === false) { + return null; + } + + $relativePath = substr($entry, $pos + strlen($marker)); + + // Security: reject paths containing directory traversal + if (str_contains($relativePath, '..')) { + return null; + } + + // Skip direktori yang dikecualikan + foreach ($skipDirs as $dir) { + if (str_starts_with($relativePath, $dir)) { + return null; + } + } + + if (empty($relativePath)) { + return null; + } + + return $relativePath; + } private function deleteTemporaryDirectory($path) { + // Fix #7: Tolak symlink dan path di luar storage/ untuk mencegah penghapusan file penting + if (is_link($path)) { + Log::warning('deleteTemporaryDirectory: path adalah symlink, dibatalkan: ' . $path); + return false; + } + + $realPath = realpath($path); + $allowedBase = realpath(storage_path()); + + if ($realPath === false || $allowedBase === false) { + Log::warning('deleteTemporaryDirectory: path tidak dapat diresolve: ' . $path); + return false; + } + + // Pastikan path berada di dalam storage/ + $normalizedReal = str_replace('\\', '/', $realPath); + $normalizedBase = str_replace('\\', '/', $allowedBase); + if (! str_starts_with($normalizedReal, $normalizedBase)) { + Log::error('deleteTemporaryDirectory: path di luar storage/, dibatalkan: ' . $realPath); + return false; + } + if (is_dir($path)) { $files = array_diff(scandir($path), ['.', '..']); foreach ($files as $file) { diff --git a/catatan_rilis.md b/catatan_rilis.md index 31a687254b..0656fc6de1 100644 --- a/catatan_rilis.md +++ b/catatan_rilis.md @@ -5,28 +5,10 @@ Terimakasih [isi disini] yang telah berkontribusi langsung mengembangkan aplikas #### FITUR -1. [#1624](https://github.com/OpenSID/OpenDK/issues/1624) Penyesuaian tombol agar fitur tambah prosedur lebih jelas & informatif. -2. [#1626](https://github.com/OpenSID/OpenDK/issues/1626) Penyesuaian tombol agar fitur potensi lebih jelas & informatif. -3. [#1645](https://github.com/OpenSID/OpenDK/issues/1645) Penyesuaian artikel desa untuk sinkronisasi API Satu Data di ubah menggunakan API. - +1. [#1616](https://github.com/OpenSID/OpenDK/issues/1616) Tambah fitur backup dan restore asset storage. #### BUG -1. [#1668](https://github.com/OpenSID/OpenDK/issues/1668) Perbaikan feed untuk database gabungan yang tidak tampil. -2. [#1669](https://github.com/OpenSID/OpenDK/issues/1669) Perbaikan foto pengurus dan media terkait tidak tampil. -3. [#1629](https://github.com/OpenSID/OpenDK/issues/1629) Perbaikan error pada statistik/anggaran-desa. -4. [#1634](https://github.com/OpenSID/OpenDK/issues/1634) Perbaiki menu aktif harus terbuka dan terlihat pada sidebar. -5. [#1622](https://github.com/OpenSID/OpenDK/issues/1622) Perbaiki tab agama pada statistik kependudukan tidak berfungsi. -6. [#1636](https://github.com/OpenSID/OpenDK/issues/1636) Perbaiki struktur organisasi. - - #### TEKNIS -1. [#1619](https://github.com/OpenSID/OpenDK/issues/1619) Perbaikan teknis terkait upload tema di OpenDK. -2. [#1630](https://github.com/OpenSID/OpenDK/issues/1630) Sesuaikan perubahan tombol aksi agar konsisten. -3. [#1632](https://github.com/OpenSID/OpenDK/issues/1632) Penyesuaian pendaftaran kerjasama. -4. [#1635](https://github.com/OpenSID/OpenDK/issues/1635) Penyesuaian alert agar konsisten. -5. [#1627](https://github.com/OpenSID/OpenDK/issues/1627) Penyesuaian UI/UX di modul event. -6. [#1628](https://github.com/OpenSID/OpenDK/issues/1628) Penyesuaian UI/UX di modul artikel. -7. [#1650](https://github.com/OpenSID/OpenDK/issues/1650) Penyesuaian menu seeder yang tidak memiliki sumber. \ No newline at end of file diff --git a/config/backup.php b/config/backup.php index 2b54adbbe0..463de2fc97 100644 --- a/config/backup.php +++ b/config/backup.php @@ -26,6 +26,8 @@ base_path('storage/debugbar'), base_path('storage/framework'), base_path('storage/logs'), + base_path('storage/app/backup-storage'), + base_path('storage/app/backup-temp'), ], /* @@ -43,7 +45,7 @@ * Set to `null` to include complete absolute path * Example: base_path() */ - 'relative_path' => null, + 'relative_path' => base_path(), ], /* diff --git a/config/database.php b/config/database.php index e04d5b1cc4..23846ada78 100644 --- a/config/database.php +++ b/config/database.php @@ -97,7 +97,7 @@ 'dump_binary_path' => env('DB_MYSQLDUMP_PATH'), // Sesuaikan dengan lokasi binary mysqldump di server // 'use_single_transaction' => true, // InnoDB // 'timeout' => 60, // Waktu timeout dalam detik - 'add_extra_option' => '--password=' . env('DB_PASSWORD', ''), + // 'add_extra_option' => '--password=' . env('DB_PASSWORD', ''), ], ], diff --git a/resources/views/setting/pengaturan_database/table-backup.blade.php b/resources/views/setting/pengaturan_database/table-backup.blade.php index 96a66650cc..3aa1829fc0 100644 --- a/resources/views/setting/pengaturan_database/table-backup.blade.php +++ b/resources/views/setting/pengaturan_database/table-backup.blade.php @@ -86,30 +86,22 @@ class: 'text-center', "_token": "{{ csrf_token() }}", }, beforeSend: function() { - restoreMessage.html('

Processing, please wait...

'); + restoreMessage.html('

Sedang di proses, mohon tunggu...

'); btnBackup.removeClass("btn-social"); btnBackup.attr("disabled", true); - btnBackup.html(' Loading...'); + btnBackup.html(' Sedang di proses...'); }, success: function(response) { $('#data-backup-database').DataTable().ajax.reload(); - btnBackup.addClass("btn-social"); - btnBackup.attr("disabled", false); - btnBackup.html('Buat Cadangan Baru Database'); + restoreMessage.html( + '

Berhasil membuat salinan database.

' + ); }, error: function(xhr, status, error) { - restoreMessage.html('

Error: ' + xhr.responseJSON - .message + '

'); - - btnBackup.addClass("btn-social"); - btnBackup.attr("disabled", false); - btnBackup.html('Buat Cadangan Baru Database'); + restoreMessage.html('

Error: ' + (xhr.responseJSON?.message || 'Gagal melakukan backup.') + '

'); }, complete: function() { - restoreMessage.html( - '

Berhasil membuat salinan database.

' - ); btnBackup.addClass("btn-social"); btnBackup.attr("disabled", false); btnBackup.html('Buat Cadangan Baru Database'); diff --git a/resources/views/setting/pengaturan_database/table-restore.blade.php b/resources/views/setting/pengaturan_database/table-restore.blade.php index 14938f5137..0f47cffd22 100644 --- a/resources/views/setting/pengaturan_database/table-restore.blade.php +++ b/resources/views/setting/pengaturan_database/table-restore.blade.php @@ -2,10 +2,18 @@ @section('content_pengaturan_database')
- - -

Unggah file database (.sql)

-
@@ -34,12 +42,15 @@ processData: false, contentType: false, success: function(response) { - restoreMessage.html('

Database restored successfully!

'); + restoreMessage.html('

' + response.message + '

'); buttonSubmit.attr("disabled", false) $('#restoreDatabaseForm')[0].reset(); }, error: function(xhr) { - restoreMessage.html('

Error: ' + xhr.responseJSON.message + '

'); + let errorMsg = (xhr.responseJSON && xhr.responseJSON.message) + ? xhr.responseJSON.message + : 'Terjadi kesalahan server. Silakan cek log aplikasi.'; + restoreMessage.html('

Error: ' + errorMsg + '

'); buttonSubmit.attr("disabled", false) $('#restoreDatabaseForm')[0].reset(); } diff --git a/tests/Feature/Settings/PengaturanDatabaseBackupTest.php b/tests/Feature/Settings/PengaturanDatabaseBackupTest.php new file mode 100644 index 0000000000..a33a83c4af --- /dev/null +++ b/tests/Feature/Settings/PengaturanDatabaseBackupTest.php @@ -0,0 +1,61 @@ +withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); +}); + +describe('createBackup - response handling', function () { + test('mengembalikan success=true ketika backup berhasil (exit code 0)', function () { + Artisan::shouldReceive('call') + ->with('backup:run') + ->andReturn(0); + + Artisan::shouldReceive('output') + ->andReturn('Backup completed!'); + + $response = $this->postJson( + route('setting.pengaturan-database.runbackup'), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(200); + $response->assertJson(['success' => true]); + }); + + test('mengembalikan success=false ketika backup gagal (exit code non-zero)', function () { + Artisan::shouldReceive('call') + ->with('backup:run') + ->andReturn(1); + + Artisan::shouldReceive('output') + ->andReturn('mysqldump not found'); + + $response = $this->postJson( + route('setting.pengaturan-database.runbackup'), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(500); + $response->assertJson(['success' => false]); + $response->assertJsonPath('message', 'Backup gagal. Periksa log aplikasi untuk detail.'); + }); +}); diff --git a/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php b/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php new file mode 100644 index 0000000000..b640599534 --- /dev/null +++ b/tests/Feature/Settings/PengaturanDatabaseRestoreTest.php @@ -0,0 +1,201 @@ +withoutMiddleware([ + Authenticate::class, + RoleMiddleware::class, + PermissionMiddleware::class, + CompleteProfile::class, + GlobalShareMiddleware::class, + ]); + + Storage::fake('public'); + + // Nonaktifkan upload_limit agar tidak mengganggu test + SettingAplikasi::updateOrCreate( + ['key' => 'upload_limit'], + ['value' => '0', 'type' => 'boolean', 'kategori' => 'sistem', 'description' => 'test', 'option' => '{}'] + ); +}); + +describe('restoreBackup - validasi ekstensi', function () { + test('menolak file dengan ekstensi tidak diizinkan (.txt)', function () { + $file = UploadedFile::fake()->create('backup.txt', 100); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(422); + $response->assertJson(['message' => 'Hanya file .zip dari backup system yang diizinkan untuk restore.']); + }); + + test('menolak file .sql (tidak lagi didukung)', function () { + $file = UploadedFile::fake()->create('backup.sql', 100); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(422); + $response->assertJson([ + 'success' => false, + 'message' => 'Hanya file .zip dari backup system yang diizinkan untuk restore.', + ]); + }); + + test('menolak file dengan ekstensi tidak diizinkan (.exe)', function () { + $file = UploadedFile::fake()->create('malware.exe', 100); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(422); + $response->assertJson(['success' => false]); + }); + + test('menolak request tanpa file', function () { + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + [], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['backupFile']); + }); +}); + +describe('restoreBackup - file ZIP corrupt', function () { + test('mengembalikan error 500 untuk ZIP yang rusak', function () { + // Buat file dengan ZIP magic bytes (PK) tapi content corrupt + // agar pass MIME validation tapi gagal di ZipArchive::open() + $tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.zip'; + file_put_contents($tmpFile, "PK\x03\x04" . str_repeat("\x00", 100)); + + $file = new UploadedFile($tmpFile, 'corrupt.zip', 'application/zip', null, true); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(500); + $response->assertJsonPath('message', 'Gagal membuka file ZIP. Pastikan file tidak rusak.'); + + // Cleanup + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + }); +}); + +describe('restoreBackup - file SQL valid diterima', function () { + test('menolak file .sql dan mengembalikan 422', function () { + $sqlContent = "-- Test SQL dump\n-- Nothing to execute\n"; + $tmpFile = tempnam(sys_get_temp_dir(), 'test_') . '.sql'; + file_put_contents($tmpFile, $sqlContent); + + $file = new UploadedFile($tmpFile, 'test-backup.sql', 'text/plain', null, true); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + // .sql tidak lagi diterima + $response->assertStatus(422); + $response->assertJson([ + 'success' => false, + 'message' => 'Hanya file .zip dari backup system yang diizinkan untuk restore.', + ]); + + if (file_exists($tmpFile)) { + unlink($tmpFile); + } + }); +}); + +describe('restoreBackup - multiple db-dump entries dalam ZIP', function () { + test('mengembalikan error 500 jika ZIP mengandung lebih dari 1 db-dump', function () { + // Buat ZIP dengan 2 file di db-dumps/ + $tmpZip = tempnam(sys_get_temp_dir(), 'test_') . '.zip'; + $zip = new ZipArchive(); + $zip->open($tmpZip, ZipArchive::CREATE); + $zip->addFromString('db-dumps/dump1.sql', '-- dump 1'); + $zip->addFromString('db-dumps/dump2.sql', '-- dump 2'); + $zip->close(); + + $file = new UploadedFile($tmpZip, 'multi-dump.zip', 'application/zip', null, true); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + $response->assertStatus(500); + $response->assertJsonPath('success', false); + expect($response->json('message'))->toContain('database dump'); + + if (file_exists($tmpZip)) { + unlink($tmpZip); + } + }); +}); + +describe('restoreBackup - ZIP slip pada db-dumps/', function () { + test('entry db-dumps dengan path traversal dilewati, tidak error fatal', function () { + // ZIP berisi 1 entry db-dumps dengan traversal path + // Entry harus di-skip, tidak crash, dan tidak mengekstrak file di luar tempBase + $tmpZip = tempnam(sys_get_temp_dir(), 'test_') . '.zip'; + $zip = new ZipArchive(); + $zip->open($tmpZip, ZipArchive::CREATE); + $zip->addFromString('db-dumps/../../evil.sql', '-- traversal'); + $zip->close(); + + $file = new UploadedFile($tmpZip, 'slip-test.zip', 'application/zip', null, true); + + $response = $this->postJson( + route('setting.pengaturan-database.runrestore'), + ['backupFile' => $file], + ['X-Requested-With' => 'XMLHttpRequest'] + ); + + // Entry traversal di-skip → tidak ada dump valid → response 200 dengan 0 file + // Yang penting: tidak 422 (file ZIP diterima) dan evil.sql tidak ada di luar tempBase + $response->assertStatus(200); + $response->assertJsonPath('success', true); + + // Pastikan file traversal tidak diekstrak ke luar direktori backup-temp + expect(file_exists(storage_path('evil.sql')))->toBeFalse(); + expect(file_exists(storage_path('app/evil.sql')))->toBeFalse(); + + if (file_exists($tmpZip)) { + unlink($tmpZip); + } + }); +}); + diff --git a/tests/Unit/PengaturanDatabaseTest.php b/tests/Unit/PengaturanDatabaseTest.php new file mode 100644 index 0000000000..eefc8d6b85 --- /dev/null +++ b/tests/Unit/PengaturanDatabaseTest.php @@ -0,0 +1,136 @@ +method = new \ReflectionMethod($controller, 'mapZipEntryToStoragePath'); + $this->method->setAccessible(true); + $this->controller = $controller; + }); + + test('memetakan path relative baru dengan benar', function () { + $result = $this->method->invoke($this->controller, 'storage/app/public/artikel/foto.jpg'); + + expect($result)->toBe('public/artikel/foto.jpg'); + }); + + test('memetakan path absolute Windows', function () { + $result = $this->method->invoke($this->controller, 'C:/laragon/www/OpenDK/storage/app/public/artikel/foto.jpg'); + + expect($result)->toBe('public/artikel/foto.jpg'); + }); + + test('memetakan path absolute Linux', function () { + $result = $this->method->invoke($this->controller, '/var/www/html/storage/app/public/artikel/foto.jpg'); + + expect($result)->toBe('public/artikel/foto.jpg'); + }); + + test('menangani path dengan backslash yang sudah dinormalkan', function () { + // Backslash normalization (str_replace('\\', '/')) dilakukan di restoreFromZip() + // sebelum memanggil method ini. Di sini kita menguji dengan path yang sudah dinormalkan. + $result = $this->method->invoke($this->controller, 'storage/app/public/foto.jpg'); + + expect($result)->toBe('public/foto.jpg'); + }); + + test('menolak path traversal', function () { + $entries = [ + 'storage/app/public/../../../etc/passwd', + 'storage/app/../../config/database.php', + ]; + + foreach ($entries as $entry) { + $result = $this->method->invoke($this->controller, $entry); + expect($result)->toBeNull(); + } + }); + + test('menolak entri db-dumps', function () { + $result = $this->method->invoke($this->controller, 'db-dumps/mysql-opendk.sql'); + + expect($result)->toBeNull(); + }); + + test('menolak direktori yang dikecualikan', function () { + $skipDirs = [ + 'storage/app/backup-storage/old-backup.zip', + 'storage/app/backup-temp/restore-dump.sql', + 'storage/app/framework/cache/data', + 'storage/app/logs/laravel.log', + 'storage/app/debugbar/trace.json', + ]; + + foreach ($skipDirs as $entry) { + $result = $this->method->invoke($this->controller, $entry); + expect($result)->toBeNull(); + } + }); + + test('menolak entri tanpa marker storage/app/', function () { + $entries = [ + 'vendor/laravel/framework/src/Illuminate/Foundation/Application.php', + 'composer.json', + 'app/Models/User.php', + ]; + + foreach ($entries as $entry) { + $result = $this->method->invoke($this->controller, $entry); + expect($result)->toBeNull(); + } + }); + + test('menolak path kosong setelah marker', function () { + $result = $this->method->invoke($this->controller, 'storage/app/'); + + expect($result)->toBeNull(); + }); + + test('menangani path dengan subdirektori dalam', function () { + $result = $this->method->invoke($this->controller, 'storage/app/public/artikel/2024/01/foto-artikel.jpg'); + + expect($result)->toBe('public/artikel/2024/01/foto-artikel.jpg'); + }); +}); + +describe('PengaturanDatabaseController - deleteTemporaryDirectory', function () { + beforeEach(function () { + $controller = app(App\Http\Controllers\Setting\PengaturanDatabaseController::class); + $this->method = new \ReflectionMethod($controller, 'deleteTemporaryDirectory'); + $this->method->setAccessible(true); + $this->controller = $controller; + }); + + test('menolak path di luar storage/ dan mengembalikan false', function () { + // Path di luar storage (misal: direktori sistem) + $outsidePath = sys_get_temp_dir() . '/outside-storage-test'; + + $result = $this->method->invoke($this->controller, $outsidePath); + + expect($result)->toBeFalse(); + }); + + test('mengembalikan false untuk path yang tidak dapat diresolve', function () { + $nonExistentPath = storage_path('app/non-existent-dir-xyz123'); + + $result = $this->method->invoke($this->controller, $nonExistentPath); + + // Path tidak ada → realpath() return false → method return false + expect($result)->toBeFalse(); + }); + + test('berhasil menghapus direktori kosong di dalam storage/', function () { + // Buat direktori sementara di dalam storage/ + $testDir = storage_path('app/test-delete-temp-' . uniqid()); + mkdir($testDir, 0755, true); + + expect(is_dir($testDir))->toBeTrue(); + + $result = $this->method->invoke($this->controller, $testDir); + + expect($result)->not->toBeFalse(); + expect(is_dir($testDir))->toBeFalse(); + }); +});