Topik ini menutup proyek Daftar Tugas dengan pemeriksaan menyeluruh atas aplikasi yang dibangun pada topik Implementasi. Daftar periksa keamanan dari perencanaan diperiksa ulang satu per satu terhadap kode, dan repository diuji dengan PHPUnit. Catatan deployment merangkum hal yang berubah ketika aplikasi berpindah dari server demo ke server produksi. Setelah topik ini diselesaikan, proyek memiliki bukti uji yang dapat dijalankan ulang kapan pun kodenya berubah, beserta daftar penyetelan yang siap diterapkan pada penyebaran sungguhan.
Pemeriksaan daftar periksa keamanan#
Lima baris daftar periksa pada perencanaan menetapkan tindakan konkret untuk setiap ancaman, dan pemeriksaan akhir membandingkan setiap barisnya dengan kode yang sudah jadi. Kode dibaca mengikuti alur permintaan, yaitu masukan form, penyimpanan ke basis data, dan keluaran HTML. Empat ancaman pertama dibuktikan dengan potongan yang disalin langsung dari berkas implementasi, sedangkan ancaman kelima dibuktikan dengan skrip yang dapat dijalankan.
Ancaman XSS (cross-site scripting) ditangani pada template, yaitu titik keluaran terakhir sebelum HTML dikirim ke browser.
// tidak-dijalankan
$e = static fn (string $nilai): string => htmlspecialchars($nilai, ENT_QUOTES, 'UTF-8');
// setiap keluaran dinamis pada templates/daftar.php melewati $e
<p><?= $e((string) $baris['judul']) ?><?= (int) $baris['selesai'] === 1 ? ' (selesai)' : ' (belum)' ?></p>
Bendera ENT_QUOTES meloloskan kutip ganda dan kutip tunggal sekaligus, sedangkan argumen UTF-8 menyatakan pengodean keluarannya. Judul yang berisi <script> dengan demikian tampil sebagai teks, dan tidak ada nilai dinamis yang dicetak tanpa melewati helper tersebut.
Ancaman SQL injection ditangani pada repository, yaitu satu-satunya tempat query ditulis.
// tidak-dijalankan
public function tambah(string $judul): int
{
$stmt = $this->pdo->prepare('INSERT INTO tugas (judul, dibuat_pada) VALUES (:judul, :dibuat_pada)');
$stmt->execute([
':judul' => $judul,
':dibuat_pada' => date('Y-m-d H:i:s'),
]);
return (int) $this->pdo->lastInsertId();
}Judul dikirim sebagai parameter terikat yang dipisahkan driver dari teks query, sehingga tanda kutip maupun kata kunci SQL pada judul tidak pernah diartikan sebagai perintah. Method tandaiSelesai dan hapus memakai pola yang sama untuk parameter id-nya.
Ancaman CSRF (cross-site request forgery) dan manipulasi parameter id ditangani pada blok POST controller.
// tidak-dijalankan
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$token = $_POST['token'] ?? '';
if (!is_string($token) || $token === '' || !hash_equals($_SESSION['token_csrf'], $token)) {
http_response_code(403);
exit('Permintaan ditolak: token CSRF tidak valid.' . PHP_EOL);
}
$aksi = $_POST['aksi'] ?? '';
if ($aksi === 'tambah') {
$judul = $_POST['judul'] ?? '';
if (is_string($judul) && trim($judul) !== '') {
$repo->tambah(trim($judul));
}
} elseif ($aksi === 'selesai' || $aksi === 'hapus') {
$id = filter_var($_POST['id'] ?? '', FILTER_VALIDATE_INT);
if ($id !== false && $id > 0) {
if ($aksi === 'selesai') {
$repo->tandaiSelesai($id);
} else {
$repo->hapus($id);
}
}
}
}Verifikasi token berada sebelum seluruh cabang aksi, dan penolakan dengan kode status 403 menghentikan permintaan sebelum satu pun data berubah. Nilai id dari luar lolos hanya bila FILTER_VALIDATE_INT mengembalikan bilangan bulat positif, sehingga teks seperti 2abc tidak pernah mencapai query.
Ancaman pencurian isi session ditangani dengan atribut cookie, dan skrip berikut meniru penyetelan pada controller lalu membaca kembali parameternya.
<?php
declare(strict_types=1);
// meniru penyetelan cookie session pada public/index.php untuk diperiksa ulang
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
$params = session_get_cookie_params();
echo 'httponly: ', var_export($params['httponly'], true), PHP_EOL;
echo 'samesite: ', $params['samesite'], PHP_EOL;httponly: true
samesite: LaxAtribut httponly membuat cookie session tidak terbaca oleh JavaScript pada halaman mana pun, sedangkan Lax menghentikan pengiriman cookie untuk permintaan POST lintas situs. Keduanya tidak menggantikan verifikasi token, karena Lax masih mengirim cookie pada navigasi tautan, sebagaimana ditegaskan modul Keamanan. Kelima baris daftar periksa dengan demikian terbukti terpasang pada titik yang tepat dalam satu alur permintaan yang sama.
Pengujian repository dengan PHPUnit#
Repository tidak menyentuh HTTP sama sekali, sehingga kelasnya dapat diuji langsung tanpa server dan tanpa browser. PHPUnit menjalankan kelas uji yang memanggil method repository lalu memeriksa hasilnya dengan assertion, yaitu pernyataan yang menggagalkan uji bila nilai yang diperiksa tidak sesuai. Salinan repository berikut berisi kode yang identik dengan berkas pada topik Implementasi, sehingga uji menguji kode yang sama dengan kode aplikasi.
<?php
declare(strict_types=1);
final class TugasRepository
{
public function __construct(private PDO $pdo)
{
}
// membuat tabel tugas; dipakai skema.php, demo CLI, dan pengujian
public static function buatSkema(PDO $pdo): void
{
$pdo->exec('CREATE TABLE tugas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
selesai INTEGER NOT NULL DEFAULT 0,
dibuat_pada TEXT NOT NULL
)');
}
public function semua(): array
{
$stmt = $this->pdo->query('SELECT id, judul, selesai, dibuat_pada FROM tugas ORDER BY id');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function tambah(string $judul): int
{
$stmt = $this->pdo->prepare('INSERT INTO tugas (judul, dibuat_pada) VALUES (:judul, :dibuat_pada)');
$stmt->execute([
':judul' => $judul,
':dibuat_pada' => date('Y-m-d H:i:s'),
]);
return (int) $this->pdo->lastInsertId();
}
public function tandaiSelesai(int $id): void
{
$stmt = $this->pdo->prepare('UPDATE tugas SET selesai = 1 WHERE id = :id');
$stmt->execute([':id' => $id]);
}
public function hapus(int $id): void
{
$stmt = $this->pdo->prepare('DELETE FROM tugas WHERE id = :id');
$stmt->execute([':id' => $id]);
}
}Kelas uji membangun skemanya sendiri pada metode setUp, yaitu di awal setiap uji, di atas basis data SQLite di memori.
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
require_once 'TugasRepository.php';
// berkas uji dijalankan melalui PHPUnit, misalnya: phpunit TugasRepositoryTest.php
if (!class_exists(TestCase::class)) {
echo 'Berkas ini dijalankan melalui PHPUnit, misalnya: phpunit TugasRepositoryTest.php', PHP_EOL;
exit;
}
final class TugasRepositoryTest extends TestCase
{
private PDO $pdo;
private TugasRepository $repo;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
TugasRepository::buatSkema($this->pdo);
$this->repo = new TugasRepository($this->pdo);
}
public function testTambahLaluSemuaMemuatTugasBaru(): void
{
$id = $this->repo->tambah('Memeriksa daftar periksa keamanan');
$semua = $this->repo->semua();
$this->assertCount(1, $semua);
$this->assertSame($id, $semua[0]['id']);
$this->assertSame('Memeriksa daftar periksa keamanan', $semua[0]['judul']);
$this->assertSame(0, $semua[0]['selesai']);
}
public function testTandaiSelesaiMengubahKolomSelesai(): void
{
$this->repo->tambah('Menjalankan uji TugasRepository');
$this->assertSame(0, $this->repo->semua()[0]['selesai']);
$this->repo->tandaiSelesai(1);
$this->assertSame(1, $this->repo->semua()[0]['selesai']);
}
public function testHapusMengosongkanDaftar(): void
{
$this->repo->tambah('Mencatat catatan deployment');
$this->assertCount(1, $this->repo->semua());
$this->repo->hapus(1);
$this->assertSame([], $this->repo->semua());
}
}Berkas ini dijalankan melalui PHPUnit, misalnya: phpunit TugasRepositoryTest.phpKeluaran di atas muncul ketika berkas uji dijalankan lewat PHP biasa. Pemeriksaan class_exists di awal berkas menandai konteks eksekusinya, karena kelas uji hanya bermakna ketika dijalankan melalui PHPUnit, sedangkan pemanggilan yang benar memakai perintah phpunit.
Pemanggilan melalui PHPUnit menjalankan ketiga uji dan mencetak hasilnya. Baris Time dan Memory pada keluarannya berubah pada setiap eksekusi, sehingga keduanya disaring agar keluaran yang ditampilkan dapat direproduksi.
# menjalankan uji repository dari folder topik
phpunit TugasRepositoryTest.php | grep -v '^Time:'PHPUnit 11.5.57 by Sebastian Bergmann and contributors.
Runtime: PHP 8.3.27
... 3 / 3 (100%)
OK (3 tests, 8 assertions)Tanda OK menegaskan ketiga uji lulus dengan delapan assertion. Uji pertama menegaskan tugas baru muncul pada semua() dengan status 0, uji kedua menegaskan kolom selesai berubah menjadi 1 setelah tandaiSelesai, dan uji ketiga menegaskan baris hilang setelah hapus. Setelah kode repository berubah, perintah ini menjadi syarat lulus sebelum perubahan dinyatakan selesai.
Catatan deployment#
Deployment, yaitu tahap menjalankan aplikasi pada server produksi, mengubah beberapa penyetelan demo. Server bawaan PHP hanya melayani satu permintaan pada satu waktu dan tidak menyertakan pengamanan server web, sehingga perannya berhenti di lingkungan pengembangan. Pada produksi, aplikasi dilayani Apache atau Nginx dengan PHP-FPM, dan folder dokumen server diarahkan ke public sehingga folder src dan templates tidak pernah terkirim ke browser.
Penyetelan error dan kinerja ditetapkan pada konfigurasi PHP produksi.
; produksi: error dicatat ke log, tidak pernah ditampilkan ke browser
display_errors=0
log_errors=1
error_log=/var/log/php/error.log
opcache.enable=1Direktif display_errors=0 mencegah pesan error yang memuat jalur berkas dan potongan kode terkirim ke browser, sedangkan log_errors=1 beserta error_log mengalirkannya ke berkas log yang dapat ditinjau. Direktif opcache.enable=1 menyimpan hasil kompilasi skrip di memori sehingga setiap permintaan tidak mengompilasi ulang berkas PHP yang sama.
Dua hal terakhir menyangkut basis data dan berkasnya. Basis data SQLite berupa satu berkas, sehingga pencadangannya cukup menyalin berkas tersebut pada jendela tanpa penulisan, misalnya setelah server dihentikan sementara. Izin berkas basis data dijaga agar hanya proses server yang dapat menulis /tmp/proyek-tugas.db, sedangkan pengguna lain cukup membaca kode aplikasi. Pada proyek nyata, lokasi basis data produksi dipindahkan dari folder /tmp ke folder khusus data milik server agar isinya tidak hilang saat pembersihan berkas sementara.
Ringkasan#
- Daftar periksa keamanan perencanaan terbukti terpasang pada kode, dari pelolosan keluaran, prepared statement, verifikasi token, pemeriksaan
id, hingga atribut cookie session. - Repository diuji dengan tiga uji PHPUnit pada basis data di memori, dan hasil
OK (3 tests, 8 assertions)menjadi syarat lulus setiap perubahan kode. - Produksi memakai web server sungguhan dengan folder dokumen
public, error yang dicatat ke log, opcache aktif, serta pencadangan dan izin berkas basis data yang dijaga.
Referensi#
- Writing Tests for PHPUnit — PHPUnit Documentation
— Cara menyusun kelas uji, termasuk metode
setUpdan assertion yang dipakai di sini. - Runtime Configuration — PHP Manual
— Daftar direktif error seperti
display_errorsdanlog_errorsbeserta artinya. - Testing — PHP The Right Way — Bagian pengujian PHP yang menempatkan PHPUnit sebagai alat baku.
- Session Management Cheat Sheet — OWASP — Panduan atribut cookie session beserta proteksi ID session.