Lima bagian terakhir kita membuktikan isolasi dengan cara yang sama: menjalankan demo, membaca outputnya, dan mengangguk puas. Cara itu jujur sekali jalan, tetapi tidak ada yang mengingat kita mengulang anggukan itu setiap kali AccessControl di-refactor atau query baru ditambahkan — dan pada aplikasi multi-tenant, satu query yang lupa filter tenant_id adalah kebocoran data lintas pelanggan, bukan sekadar bug tampilan. Pada bagian ini kita serahkan pembuktian itu kepada PHPUnit: lima belas test membangun database :memory: yang segar untuk masing-masing dirinya, lalu memanggil setiap operasi dengan id milik tenant lain dan menuntut hasil kosong atau ditolak — janji yang diucapkan Bagian 2 akhirnya diperiksa mesin, bukan mata. Sepanjang jalan kita juga mengurus satu jebakan khas PHP: state statis TenantContext yang menembus batas antar test.
Isolasi adalah Properti Keamanan #
Keamanan aplikasi multi-tenant proyek ini bukan satu fitur, melainkan tumpukan lima gerbang yang bekerja berurutan. Gerbang pertama adalah autentikasi: password_verify() memastikan budi memang budi sebelum ada pertanyaan lain — materi lengkapnya ada di chapter Password Hashing
. Gerbang kedua adalah keanggotaan: TenantContext::fromUser() mengembalikan null untuk user yang bukan anggota tenant, berapa pun kali mencoba. Gerbang ketiga adalah izin: AccessControl::can() menolak aksi lintas tenant dan aksi tak dikenal secara default. Gerbang keempat adalah query ter-scope: syarat ganda id + tenant_id membuat proyek #3 mustahil diambil dari konteks tenant #1. Gerbang kelima adalah jejak: audit trail merekam apa yang lolos keempat gerbang, dan trigger append-only menjaga rekamannya tak bisa ditulis ulang.
Test suite bagian ini menulis satu test untuk setiap gerbang, dan yang paling penting: setiap test berusaha MENEMBUS gerbang dari arah yang salah. Ini pola berpikir penyerang, bukan pola berpikir pengguna — budi (owner penuh di tenant #1) mencoba membuka tenant #2, dewi berpindah konteks dan membandingkan pemandangannya, dan query tunggal menodong proyek milik tetangga. Pemetaan invarian ke test-nya:
| Invarian yang dijaga | Test yang menjaganya |
|---|---|
| Autentikasi adalah gerbang terluar | testSandiDemoTerverifikasiTerhadapHashBeku |
| Context hanya untuk anggota | testContextLintasTenantDitolakUntukNonAnggota |
| User global boleh dua tenant, role masing-masing | testContextUserGlobalBisaDuaTenantRoleBerbeda |
| Daftar data menyusut mengikuti context | testProyekTenantSatuTidakTerlihatDariKonteksTenantDua |
Akses tunggal butuh syarat ganda id + tenant_id | testPengambilanProyekLintasTenantMengembalikanNull |
| Keanggotaan tidak terbaca lintas tenant | testKeanggotaanLintasTenantTidakDitemukan |
| Izin tidak menembus batas tenant | testAccessControlMenolakAksiLintasTenant |
| Aksi tak dikenal ditolak untuk semua role | testAksiTakDikenalDitolakUntukSemuaRole |
| Riwayat audit terbagi per tenant | testRiwayatAuditTerbagiPerTenant |
| Riwayat audit tak bisa ditulis ulang | testAuditLogMenolakTulisUlang |
| Batas dan status plan per tenant, tidak menular | testBatasLanggananBerlakuPerTenant, testTenggangHanyaMengunciTenantYangBerlangganan |
| Job membawa tenant sepanjang siklus | testJobMembawaKonteksTenantSepanjangSiklus, testJobGagalTigaKaliMenjadiDeadLetterDenganTenant |
| State context tidak bocor antar test | testContextDimulaiKosongDiTestBerikutnya |
Prepared statements menjaga gerbang keenam yang tidak kita tes di sini — injeksi SQL — karena seluruh query sejak Bagian 2 memakai parameter binding; pembahasan penuhnya ada di chapter SQL Injection .
Dunia Baru untuk Setiap Test #
Setiap test berjalan di setUp() yang membangun dunianya sendiri: koneksi :memory: baru, PRAGMA foreign_keys = ON, lalu tiga berkas SQL dieksekusi berurutan — schema.sql Bagian 1, migrasi-billing.sql Bagian 4, migrasi-audit-jobs.sql Bagian 5. Urutan ini bukan formalitas: foreign key kedua migrasi menunjuk tabel tenants dan users pada schema dasar, dan membalik urutannya berhenti di PDOException sebelum test pertama sempat berjalan. Hasilnya fixture yang selalu identik: 2 tenant, 4 user, 4 keanggotaan, 5 proyek, 2 plan — angka-angka yang dipakai seluruh assertion.
tearDown() hanya berisi satu baris, tetapi baris itu adalah bintang kecil suite ini: TenantContext::clear(). TenantContext menyimpan context di property statis, dan property statis hidup sepanjang proses PHP — termasuk sepanjang satu eksekusi phpunit yang menjalankan lima belas test secara berurutan. Tanpa clear(), context yang dipasang testProyekTenantSatuTidakTerlihatDariKonteksTenantDua masih terpasang saat test berikutnya berjalan; test itu sendiri tidak rusak karena memasang context-nya sendiri, tetapi test yang berasumsi dunia bersih akan membaca pemandangan tenant yang salah — dan lulus, karena query ter-scope tetap bekerja “benar” terhadap context yang salah.
tests/bootstrap.php — Memuat Class Antar Bagian #
Sebelum test pertama bisa berjalan, PHPUnit perlu tahu di mana kedelapan class proyek hidup. Pada proyek nyata jawabannya satu baris require 'vendor/autoload.php'; pada bundle kursus ini class tersebar di folder src/ milik Bagian 2 sampai 5, sehingga bootstrap menyeberang antar folder bagian yang bersaudara. Berikut isi lengkap tests/bootstrap.php:
<?php
declare(strict_types=1);
/**
* Bootstrap PHPUnit untuk suite isolasi aplikasi SaaS multi-tenant.
*
* Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
* test pertama berjalan, lewat path relatif antar folder bagian.
*
* Pada proyek nyata, class hidup dalam satu folder src/ dengan
* namespace dan autoloader Composer, sehingga berkas semacam ini cukup
* memanggil require 'vendor/autoload.php'. Pada bundle kursus ini,
* class justru tersebar di folder src/ milik masing-masing bagian agar
* setiap halaman bagian mandiri - folder bagian bersaudara di bawah
* aplikasi-saas-multi-tenant/, maka dari tests/ kita naik dua tingkat
* ('/../..') lalu masuk ke bagian tujuan. require_once menjaga berkas
* yang sama tidak dimuat dua kali bila suite diperluas dengan test
* file lain.
*/
// Bagian 2: identitas tenant aktif + resolusi tenant.
require_once __DIR__ . '/../../bagian-2-tenant-isolation/src/TenantContext.php';
require_once __DIR__ . '/../../bagian-2-tenant-isolation/src/TenantRepository.php';
// Bagian 3: keanggotaan dan matriks izin.
require_once __DIR__ . '/../../bagian-3-membership-dan-permissions/src/MembershipRepository.php';
require_once __DIR__ . '/../../bagian-3-membership-dan-permissions/src/AccessControl.php';
// Bagian 4: katalog plan dan siklus langganan.
require_once __DIR__ . '/../../bagian-4-billing-dan-subscriptions/src/PlanRepository.php';
require_once __DIR__ . '/../../bagian-4-billing-dan-subscriptions/src/SubscriptionService.php';
// Bagian 5: audit trail dan job queue.
require_once __DIR__ . '/../../bagian-5-audit-dan-background-jobs/src/AuditLogger.php';
require_once __DIR__ . '/../../bagian-5-audit-dan-background-jobs/src/JobQueue.php';
/**
* Path absolut ke berkas struktur database. setUp() tiap test
* mengeksekusi ketiganya berurutan pada koneksi :memory: yang baru:
* schema dasar dulu (tenants/users/memberships/projects), lalu dua
* migrasi - urutan ini wajib karena foreign key kedua migrasi
* menunjuk tabel pada schema dasar.
*/
const SCHEMA_SAAS = __DIR__ . '/../../bagian-1-tenant-dan-domain-model/schema.sql';
const MIGRASI_BILLING = __DIR__ . '/../../bagian-4-billing-dan-subscriptions/migrasi-billing.sql';
const MIGRASI_AUDIT_JOBS = __DIR__ . '/../../bagian-5-audit-dan-background-jobs/migrasi-audit-jobs.sql';Dua keputusan pada berkas tersebut perlu dijelaskan. Path __DIR__ . '/../../...' naik dua tingkat dari tests/ — satu ke folder bagian ini, satu lagi ke aplikasi-saas-multi-tenant/ — lalu turun ke bagian tujuan; pola yang sama dengan require antar bagian pada demo-demo sebelumnya. Tiga konstanta SCHEMA_SAAS, MIGRASI_BILLING, dan MIGRASI_AUDIT_JOBS memusatkan lokasi struktur database di satu tempat, sekaligus mendokumentasikan urutan pemuatan yang wajib — setUp() tinggal membacanya berurutan tanpa memilih-milih path lagi.
Pemisahan bootstrap dari berkas test adalah keputusan struktur: memuat class adalah urusan wiring, sedangkan berkas test adalah urusan perilaku. PHPUnit menyediakan titik sambungnya lewat opsi --bootstrap, dan bila suatu saat suite membesar ke beberapa berkas test, semuanya cukup menunjuk bootstrap yang sama.
Menulis TenantIsolationTest #
Satu berkas test berisi class TenantIsolationTest dengan lima belas method test*. Berikut kerangka intinya — setUp(), tearDown(), dan tujuh test yang menjadi jantung isolasi:
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Suite untuk invarian keamanan aplikasi SaaS multi-tenant.
*
* Isolasi data ADALAH properti keamanan proyek ini: satu database
* bersama banyak pelanggan, dan satu-satunya penjaga batas adalah
* penyaringan tenant_id pada setiap query. Suite ini memanggil
* setiap operasi dengan id milik tenant lain dan menuntut hasil
* kosong/ditolak - janji yang diucapkan pada Bagian 2 sampai 5
* kini diperiksa mesin, bukan mata.
*
* Fixture seed yang dipakai seluruh test (schema.sql Bagian 1):
* tenant #1 toko-bunga-melati, #2 studio-foto-cendrawasih; user #1
* budi (owner t1), #2 siti (owner t2), #3 dewi (member t1 SEKALIGUS
* admin t2 - user global), #4 rudi (tanpa keanggotaan); proyek
* #1,#2 milik t1 dan #3,#4,#5 milik t2.
*
* Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
* jalankan dari folder bagian ini dengan:
* php phpunit.phar --no-progress tests/TenantIsolationTest.php --bootstrap tests/bootstrap.php
*/
final class TenantIsolationTest extends TestCase
{
private PDO $pdo;
private MembershipRepository $memberships;
private AccessControl $access;
private SubscriptionService $subscriptions;
private AuditLogger $audit;
private JobQueue $queue;
protected function setUp(): void
{
// Database :memory: baru untuk setiap test, dengan seluruh
// struktur dimuat berurutan: schema dasar lalu dua migrasi.
// Fixture selalu identik (2 tenant, 4 user, 5 proyek, 2 plan).
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('PRAGMA foreign_keys = ON');
$this->pdo->exec((string) file_get_contents(SCHEMA_SAAS));
$this->pdo->exec((string) file_get_contents(MIGRASI_BILLING));
$this->pdo->exec((string) file_get_contents(MIGRASI_AUDIT_JOBS));
$this->memberships = new MembershipRepository($this->pdo);
$this->access = new AccessControl($this->memberships);
$this->subscriptions = new SubscriptionService($this->pdo, new PlanRepository($this->pdo));
$this->audit = new AuditLogger($this->pdo);
$this->queue = new JobQueue($this->pdo);
}
protected function tearDown(): void
{
// TenantContext menyimpan state statis yang HIDUP menembus
// batas method. Tanpa clear(), context dari test sebelumnya
// bocor ke test berikutnya dan membuat
// testContextDimulaiKosongDiTestBerikutnya gagal.
TenantContext::clear();
}
public function testContextLintasTenantDitolakUntukNonAnggota(): void
{
// Budi owner tenant #1, bukan anggota tenant #2 - tidak
// ada context untuknya di sana, berapa pun kali mencoba.
$this->assertNull(TenantContext::fromUser($this->pdo, 1, 'studio-foto-cendrawasih'));
$this->assertNull(TenantContext::fromUser($this->pdo, 2, 'toko-bunga-melati'));
// Rudi terdaftar sebagai user global tetapi tanpa keanggotaan
// apa pun: tidak ada context di tenant mana pun.
$this->assertNull(TenantContext::fromUser($this->pdo, 4, 'toko-bunga-melati'));
$this->assertNull(TenantContext::fromUser($this->pdo, 4, 'studio-foto-cendrawasih'));
}
public function testProyekTenantSatuTidakTerlihatDariKonteksTenantDua(): void
{
// Dewi berangkat sebagai member tenant #1.
TenantContext::set(TenantContext::fromUser($this->pdo, 3, 'toko-bunga-melati'));
$dariToko = $this->namaProyekDiContext();
// Lalu pindah context ke tenant #2 sebagai admin.
TenantContext::clear();
TenantContext::set(TenantContext::fromUser($this->pdo, 3, 'studio-foto-cendrawasih'));
$dariStudio = $this->namaProyekDiContext();
$this->assertSame(['Website Baru', 'Kampanye Valentine'], $dariToko);
$this->assertSame(
['Fotografi Produk Sabun', 'Branding Ulang 2026', 'Katalog Musim Hujan'],
$dariStudio
);
$this->assertSame([], array_intersect($dariToko, $dariStudio));
}
public function testPengambilanProyekLintasTenantMengembalikanNull(): void
{
// Syarat ganda id + tenant_id: proyek #3 milik tenant #2
// tidak bisa diambil dengan konteks tenant #1, dan sebaliknya.
$this->assertNull($this->proyekUntukTenant(3, 1));
$this->assertNull($this->proyekUntukTenant(1, 2));
$this->assertNull($this->proyekUntukTenant(99, 1));
$milikStudio = $this->proyekUntukTenant(3, 2);
$this->assertNotNull($milikStudio);
$this->assertSame('Fotografi Produk Sabun', $milikStudio['nama']);
}
public function testAccessControlMenolakAksiLintasTenant(): void
{
// Pemilik tenant #1 kehilangan seluruh kekuasaannya di
// tenant #2; rudi ditolak di mana-mana; di dalam tenant
// sendiri izin tetap berjalan normal.
$this->assertFalse($this->access->can(1, 'project.view', 2));
$this->assertFalse($this->access->can(2, 'project.view', 1));
$this->assertFalse($this->access->can(4, 'project.view', 1));
$this->assertFalse($this->access->can(4, 'project.view', 2));
$this->assertTrue($this->access->can(1, 'project.view', 1));
$this->assertTrue($this->access->can(3, 'member.remove', 2));
}
public function testRiwayatAuditTerbagiPerTenant(): void
{
// Dua tenant merekam peristiwa pada entitas bernomor sama -
// id 6 belum terpakai pada seed, jadi tidak perlu insert
// proyek sungguhan untuk menguji pembacaannya.
$this->audit->record(1, 1, 'project.create', 'project', 6,
'{"nama":"Landing Page Lebaran"}', $this->z('2026-02-02 09:05:00'));
$this->audit->record(2, 2, 'project.create', 'project', 6,
'{"nama":"Kampanye Promo Studio"}', $this->z('2026-02-02 10:00:00'));
$riwayatToko = $this->audit->riwayat(1, 'project', 6);
$riwayatStudio = $this->audit->riwayat(2, 'project', 6);
$this->assertCount(1, $riwayatToko);
$this->assertCount(1, $riwayatStudio);
$this->assertSame('{"nama":"Landing Page Lebaran"}', $riwayatToko[0]['detail']);
$this->assertSame('{"nama":"Kampanye Promo Studio"}', $riwayatStudio[0]['detail']);
}
public function testBatasLanggananBerlakuPerTenant(): void
{
$now = $this->z('2026-02-01 09:00:00');
// Tenant #1 memegang 2 proyek: masih bisa membuat yang ketiga.
$this->assertTrue($this->subscriptions->bolehBuatProyek(1, $now));
$this->tambahProyek(1, 'Landing Page Lebaran');
$this->assertFalse($this->subscriptions->bolehBuatProyek(1, $now));
$this->assertSame(
'Batas plan Gratis: 3 proyek (saat ini 3).',
$this->subscriptions->alasanBlokir(1, $now)
);
// Tenant #2 memegang 3 proyek sejak seed: penuh sejak awal.
$this->assertFalse($this->subscriptions->bolehBuatProyek(2, $now));
// Budi meng-upgrade tenant #1 ke plan Pro: batas naik ke 25
// untuk tenant #1 saja - tenant #2 tidak ikut terangkat.
$this->subscriptions->subscribe(1, 'pro', $now);
$this->assertSame(25, $this->subscriptions->batasProyekEfektif(1, $now));
$this->assertTrue($this->subscriptions->bolehBuatProyek(1, $now));
$this->assertSame(3, $this->subscriptions->batasProyekEfektif(2, $now));
$this->assertSame('tidak-berlangganan', $this->subscriptions->status(2, $now));
}
// Delapan test lain pada berkas bundle melengkapi tabel invarian
// di atas: verifikasi password_verify terhadap hash beku, context
// user global dewi dengan dua role, keanggotaan lintas tenant,
// aksi tak dikenal, penolakan tulis-ulang audit_log, masa tenggang
// yang tidak menular ke tenant tetangga, serta dua siklus job
// (happy path dan dead letter dengan tenant_id utuh).
/**
* Test ini diletakkan SETELAH test-test yang memasang context,
* dan bergantung pada urutan deklarasi (default PHPUnit): bila
* tearDown() lupa memanggil clear(), context test sebelumnya
* masih terpasang dan get() tidak melempar - test ini gagal.
*/
public function testContextDimulaiKosongDiTestBerikutnya(): void
{
$this->expectException(RuntimeException::class);
TenantContext::get();
}
/**
* Jam beku WIB - pola yang sama dengan SubscriptionService.
*/
private function z(string $waktu): DateTimeImmutable
{
return new DateTimeImmutable($waktu, new DateTimeZone('Asia/Jakarta'));
}
/**
* Daftar nama proyek yang terbaca dari context aktif.
*
* Query mengikuti pola repository Bagian 2: tenant_id selalu
* berasal dari TenantContext, bukan dari parameter request.
*
* @return list<string>
*/
private function namaProyekDiContext(): array
{
$stmt = $this->pdo->prepare(
'SELECT nama FROM projects WHERE tenant_id = :tenant_id ORDER BY id'
);
$stmt->execute(['tenant_id' => TenantContext::get()->tenantId]);
return array_map(
fn (array $baris): string => (string) $baris['nama'],
$stmt->fetchAll(PDO::FETCH_ASSOC),
);
}
/**
* Ambil satu proyek dengan syarat ganda id + tenant_id -
* pola findForTenant() Bagian 2 yang menolak IDOR lintas tenant.
*
* @return array<string, mixed>|null
*/
private function proyekUntukTenant(int $projectId, int $tenantId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, nama FROM projects
WHERE id = :id AND tenant_id = :tenant_id'
);
$stmt->execute(['id' => $projectId, 'tenant_id' => $tenantId]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
private function tambahProyek(int $tenantId, string $nama): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO projects (tenant_id, nama) VALUES (:tenant_id, :nama)'
);
$stmt->execute(['tenant_id' => $tenantId, 'nama' => $nama]);
}
}Empat keputusan pada suite tersebut menjadi inti pembelajarannya:
- Assertion memakai
assertSame()yang membandingkan nilai sekaligus tipe: role'member'tidak boleh lolos sebagai angka, dan id tenant1integer tidak boleh lolos sebagai string'1'— perbedaan tipe pada data yang menentukan batas keamanan harus terdengar sebagai kegagalan. Perkecualiannya justru menegaskan aturan: nilai dari PDO sengaja di-cast(int)di helper sebelum dibandingkan, karena SQLite mengembalikan angka sebagai string. - Helper meniru pola production, bukan menyingkatnya.
namaProyekDiContext()mengambiltenant_iddariTenantContext::get()— persis seperti repository pada Bagian 2 — sehingga yang diuji adalah pola aslinya.proyekUntukTenant()adalahfindForTenant()dalam bentuk test: syarat gandaid + tenant_id. testProyekTenantSatuTidakTerlihatDariKonteksTenantDuamemakaiarray_intersect()sebagai assertion isolasi murni: bukan hanya jumlah baris yang benar, tetapi tidak ada satu pun nama proyek yang muncul di kedua pemandangan. Isolasi didefinisikan sebagai himpunan kosong.- Test terakhir menguji kebersihan suite-nya sendiri.
testContextDimulaiKosongDiTestBerikutnyamenuntutTenantContext::get()melemparRuntimeExceptiondi awal test — sesuatu yang hanya terjadi bila seluruhtearDown()sebelumnya benar-benar memanggilclear(). Hapus satu baris itu dan test ini gagal; kita akan membuktikannya sebentar lagi.
Rasa Isolasi Tanpa PHPUnit #
Sebelum menjalankan PHPUnit, mari bongkar bahwa test pada dasarnya hanya perbandingan dan berteriak. Harness polos berikut menguji inti isolasi — perpindahan context dewi, kebocoran query tanpa filter, dan IDOR — memakai if dan echo saja:
<?php
require __DIR__ . '/../bagian-2-tenant-isolation/src/TenantContext.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/../bagian-1-tenant-dan-domain-model/schema.sql'));
function cek(bool $kondisi, string $nama): void
{
echo $kondisi ? 'LULUS' : 'GAGAL';
echo " - $nama\n";
}
echo "== Dewi (user global) masuk dua tenant ==\n";
$sebagaiToko = TenantContext::fromUser($pdo, 3, 'toko-bunga-melati');
$sebagaiStudio = TenantContext::fromUser($pdo, 3, 'studio-foto-cendrawasih');
cek($sebagaiToko !== null && $sebagaiStudio !== null, 'context dua tenant berhasil dibuat');
cek($sebagaiToko->role === 'member' && $sebagaiStudio->role === 'admin', 'role berbeda per tenant (member vs admin)');
echo "\n== Daftar proyek mengikuti context ==\n";
function jumlahProyekTerlihat(PDO $pdo): int
{
$stmt = $pdo->prepare('SELECT COUNT(*) FROM projects WHERE tenant_id = :tenant_id');
$stmt->execute(['tenant_id' => TenantContext::get()->tenantId]);
return (int) $stmt->fetchColumn();
}
TenantContext::set($sebagaiToko);
$diToko = jumlahProyekTerlihat($pdo);
TenantContext::clear();
TenantContext::set($sebagaiStudio);
$diStudio = jumlahProyekTerlihat($pdo);
TenantContext::clear();
cek($diToko === 2 && $diStudio === 3, "context toko melihat $diToko, context studio melihat $diStudio");
$total = (int) $pdo->query('SELECT COUNT(*) FROM projects')->fetchColumn();
echo "query TANPA filter tenant_id melihat $total proyek dari 2 tenant - inilah kebocorannya\n";
echo "\n== IDOR: proyek #3 (milik studio) dari konteks toko ==\n";
TenantContext::set($sebagaiToko);
$stmt = $pdo->prepare('SELECT nama FROM projects WHERE id = :id AND tenant_id = :tenant_id');
$stmt->execute(['id' => 3, 'tenant_id' => TenantContext::get()->tenantId]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
cek($baris === false, 'pengambilan lintas tenant mengembalikan null (404)');
TenantContext::clear();
echo "\n== Context tanpa keanggotaan ditolak ==\n";
$budiDiStudio = TenantContext::fromUser($pdo, 1, 'studio-foto-cendrawasih');
$rudiDiToko = TenantContext::fromUser($pdo, 4, 'toko-bunga-melati');
cek($budiDiStudio === null, 'budi (owner toko) tidak dapat context studio');
cek($rudiDiToko === null, 'rudi (tanpa keanggotaan) tidak mendapat context');Output yang Dihasilkan:
== Dewi (user global) masuk dua tenant ==
LULUS - context dua tenant berhasil dibuat
LULUS - role berbeda per tenant (member vs admin)
== Daftar proyek mengikuti context ==
LULUS - context toko melihat 2, context studio melihat 3
query TANPA filter tenant_id melihat 5 proyek dari 2 tenant - inilah kebocorannya
== IDOR: proyek #3 (milik studio) dari konteks toko ==
LULUS - pengambilan lintas tenant mengembalikan null (404)
== Context tanpa keanggotaan ditolak ==
LULUS - budi (owner toko) tidak dapat context studio
LULUS - rudi (tanpa keanggotaan) tidak mendapat contextEnam baris LULUS itu setara dengan enam test hijau — dan satu baris tanpa LULUS justru yang paling mendidik: query tanpa filter tenant_id melihat 5 proyek dari 2 tenant, angka yang tidak pernah boleh muncul di layar pengguna mana pun. Baris itu sengaja dicetak untuk mengingatkan lawan dari apa yang kita jaga; PHPUnit membungkus perbandingan yang sama menjadi assertion bernama, laporan terstruktur, dan exit code. Fungsi cek() di sini adalah assertTrue() versi polos, dan $baris === false pada blok IDOR adalah assertNull() versi polos.
Menjalankan Suite dengan PHPUnit #
Saatnya versi sungguhannya. Dari folder bagian-6-security-dan-testing, arahkan PHPUnit ke berkas test beserta bootstrap-nya:
php phpunit.phar --no-progress tests/TenantIsolationTest.php --bootstrap tests/bootstrap.phpKami menjalankan perintah tersebut pada lingkungan bagian ini (PHP 8.4.23, PHPUnit 12.5.39) dan berikut output lengkapnya:
PHPUnit 12.5.39 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.23
Time: 00:00.441, Memory: 28.05 MB
OK (15 tests, 72 assertions)Baris OK (15 tests, 72 assertions) adalah ringkasannya: lima belas test dijalankan, tujuh puluh dua assertion dievaluasi, semuanya lulus. Waktu setengah detik itu hampir seluruhnya milik satu test — dua pemanggilan password_verify() terhadap hash bcrypt cost 12 memang sengaja lambat, itulah harganya hashing yang layak; empat belas test lainnya selesai dalam belasan milidetik. Angka Time dan Memory bervariasi antar mesin dan antar jalan, jadi jangan dikhawatirkan; opsi --no-progress kita pakai agar output stabil tanpa animasi progress bar. Exit code perintah ini adalah 0 — angka inilah yang dibaca pipeline CI agar commit yang merusak isolasi langsung tertahan, sebagaimana dibahas pada chapter Continuous Integration
.
Setelah mengembalikan clear() ke tempatnya, suite kembali OK. Jalankan perintah ini setiap kali menyentuh src/ proyek — setengah detik bukan alasan untuk menunda, dan setiap penyelamatan regresi isolasi selalu berawal dari satu php yang dijalankan tanpa malas.
Apa yang Berubah #
Proyek mendapatkan penjaga regresi untuk properti keamanannya yang paling penting:
- Berkas
tests/bootstrap.phpbaru: memuat kedelapan class dari Bagian 2-5 lewat path relatif antar bagian, plus tiga konstanta path SQL yang mendokumentasikan urutan pemuatan schema dan dua migrasi. - Berkas
tests/TenantIsolationTest.phpbaru: lima belas test pada database:memory:masing-masing — verifikasi sandi demo terhadap hash beku, context lintas tenant ditolak, context user global dua role, daftar proyek menyusut per context, IDOR lintas tenantnull, keanggotaan dan izin lintas tenant ditolak, aksi tak dikenal ditolak, riwayat audit terbagi per tenant, tulis-ulang audit diblokir trigger, batas dan tenggang plan per tenant tanpa menular, dua siklus job dengantenant_idutuh sampai dead letter, dan context bersih di awal tiap test. tearDown()memanggilTenantContext::clear()— dan test terakhir membuktikan panggilan itu diperlukan: menghapusnya membuat suite gagal tepat satu test.- Tidak ada satu baris pun class produksi yang berubah untuk kepentingan test; yang dibutuhkan hanyalah PDO yang disuntikkan, waktu yang dibekukan, dan schema+migrasi yang dimuat berurutan — tiga keputusan arsitektur Bagian 1-5 yang kini terbayar penuh.
- Bukti terdokumentasi:
OK (15 tests, 72 assertions)dengan exit code0pada PHP 8.4.23 dan PHPUnit 12.5.39.
Ringkasan #
Suite ini mengubah lima bagian janji menjadi mekanisme yang berdiri sendiri: setiap gerbang keamanan — sandi, keanggotaan, context, izin, query ter-scope, audit — kini punya test yang mencoba menembusnya dari arah salah dan menuntut kegagalan. Yang tersisa dari tujuh bagian proyek adalah pertanyaan terakhir: bagaimana seluruh tumpukan ini dikemas agar berjalan identik di server lain, dipantau kesehatannya tanpa menunggu keluhan pelanggan, dan dipulihkan saat bencana terjadi? Bagian 7 menjawabnya dengan Docker, migrasi berurutan saat start, health check, dan checklist kesiapan produksi.
Referensi #
- PHPUnit 12.5 — dokumentasi resmi
- password_verify — php.net
- Database Testing — Course PHP
- Menulis PHPUnit Tests — Course PHP
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
tests/bootstrap.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Bootstrap PHPUnit untuk suite isolasi aplikasi SaaS multi-tenant.
*
* Tugas berkas ini satu: memuat seluruh class proyek SEKALI sebelum
* test pertama berjalan, lewat path relatif antar folder bagian.
*
* Pada proyek nyata, class hidup dalam satu folder src/ dengan
* namespace dan autoloader Composer, sehingga berkas semacam ini cukup
* memanggil require 'vendor/autoload.php'. Pada bundle kursus ini,
* class justru tersebar di folder src/ milik masing-masing bagian agar
* setiap halaman bagian mandiri - folder bagian bersaudara di bawah
* aplikasi-saas-multi-tenant/, maka dari tests/ kita naik dua tingkat
* ('/../..') lalu masuk ke bagian tujuan. require_once menjaga berkas
* yang sama tidak dimuat dua kali bila suite diperluas dengan test
* file lain.
*/
// Bagian 2: identitas tenant aktif + resolusi tenant.
require_once __DIR__ . '/../../bagian-2-tenant-isolation/src/TenantContext.php';
require_once __DIR__ . '/../../bagian-2-tenant-isolation/src/TenantRepository.php';
// Bagian 3: keanggotaan dan matriks izin.
require_once __DIR__ . '/../../bagian-3-membership-dan-permissions/src/MembershipRepository.php';
require_once __DIR__ . '/../../bagian-3-membership-dan-permissions/src/AccessControl.php';
// Bagian 4: katalog plan dan siklus langganan.
require_once __DIR__ . '/../../bagian-4-billing-dan-subscriptions/src/PlanRepository.php';
require_once __DIR__ . '/../../bagian-4-billing-dan-subscriptions/src/SubscriptionService.php';
// Bagian 5: audit trail dan job queue.
require_once __DIR__ . '/../../bagian-5-audit-dan-background-jobs/src/AuditLogger.php';
require_once __DIR__ . '/../../bagian-5-audit-dan-background-jobs/src/JobQueue.php';
/**
* Path absolut ke berkas struktur database. setUp() tiap test
* mengeksekusi ketiganya berurutan pada koneksi :memory: yang baru:
* schema dasar dulu (tenants/users/memberships/projects), lalu dua
* migrasi - urutan ini wajib karena foreign key kedua migrasi
* menunjuk tabel pada schema dasar.
*/
const SCHEMA_SAAS = __DIR__ . '/../../bagian-1-tenant-dan-domain-model/schema.sql';
const MIGRASI_BILLING = __DIR__ . '/../../bagian-4-billing-dan-subscriptions/migrasi-billing.sql';
const MIGRASI_AUDIT_JOBS = __DIR__ . '/../../bagian-5-audit-dan-background-jobs/migrasi-audit-jobs.sql';
tests/TenantIsolationTest.php
Unduh berkas<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Suite untuk invarian keamanan aplikasi SaaS multi-tenant.
*
* Isolasi data ADALAH properti keamanan proyek ini: satu database
* bersama banyak pelanggan, dan satu-satunya penjaga batas adalah
* penyaringan tenant_id pada setiap query. Suite ini memanggil
* setiap operasi dengan id milik tenant lain dan menuntut hasil
* kosong/ditolak - janji yang diucapkan pada Bagian 2 sampai 5
* kini diperiksa mesin, bukan mata.
*
* Fixture seed yang dipakai seluruh test (schema.sql Bagian 1):
* tenant #1 toko-bunga-melati, #2 studio-foto-cendrawasih; user #1
* budi (owner t1), #2 siti (owner t2), #3 dewi (member t1 SEKALIGUS
* admin t2 - user global), #4 rudi (tanpa keanggotaan); proyek
* #1,#2 milik t1 dan #3,#4,#5 milik t2.
*
* Class dimuat oleh tests/bootstrap.php, bukan oleh berkas ini;
* jalankan dari folder bagian ini dengan:
* php phpunit.phar --no-progress tests/TenantIsolationTest.php --bootstrap tests/bootstrap.php
*/
final class TenantIsolationTest extends TestCase
{
private PDO $pdo;
private MembershipRepository $memberships;
private AccessControl $access;
private SubscriptionService $subscriptions;
private AuditLogger $audit;
private JobQueue $queue;
protected function setUp(): void
{
// Database :memory: baru untuk setiap test, dengan seluruh
// struktur dimuat berurutan: schema dasar lalu dua migrasi.
// Fixture selalu identik (2 tenant, 4 user, 5 proyek, 2 plan).
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('PRAGMA foreign_keys = ON');
$this->pdo->exec((string) file_get_contents(SCHEMA_SAAS));
$this->pdo->exec((string) file_get_contents(MIGRASI_BILLING));
$this->pdo->exec((string) file_get_contents(MIGRASI_AUDIT_JOBS));
$this->memberships = new MembershipRepository($this->pdo);
$this->access = new AccessControl($this->memberships);
$this->subscriptions = new SubscriptionService($this->pdo, new PlanRepository($this->pdo));
$this->audit = new AuditLogger($this->pdo);
$this->queue = new JobQueue($this->pdo);
}
protected function tearDown(): void
{
// TenantContext menyimpan state statis yang HIDUP menembus
// batas method. Tanpa clear(), context dari test sebelumnya
// bocor ke test berikutnya dan membuat
// testContextDimulaiKosongDiTestBerikutnya gagal.
TenantContext::clear();
}
public function testSandiDemoTerverifikasiTerhadapHashBeku(): void
{
$hash = $this->hashUser(1);
$this->assertTrue(password_verify('sandi-budi-2026', $hash));
$this->assertFalse(password_verify('sandi-salah-2026', $hash));
}
public function testContextUserGlobalBisaDuaTenantRoleBerbeda(): void
{
$sebagaiToko = TenantContext::fromUser($this->pdo, 3, 'toko-bunga-melati');
$this->assertNotNull($sebagaiToko);
$this->assertSame(1, $sebagaiToko->tenantId);
$this->assertSame('member', $sebagaiToko->role);
$sebagaiStudio = TenantContext::fromUser($this->pdo, 3, 'studio-foto-cendrawasih');
$this->assertNotNull($sebagaiStudio);
$this->assertSame(2, $sebagaiStudio->tenantId);
$this->assertSame('admin', $sebagaiStudio->role);
}
public function testContextLintasTenantDitolakUntukNonAnggota(): void
{
// Budi owner tenant #1, bukan anggota tenant #2 - tidak
// ada context untuknya di sana, berapa pun kali mencoba.
$this->assertNull(TenantContext::fromUser($this->pdo, 1, 'studio-foto-cendrawasih'));
$this->assertNull(TenantContext::fromUser($this->pdo, 2, 'toko-bunga-melati'));
// Rudi terdaftar sebagai user global tetapi tanpa keanggotaan
// apa pun: tidak ada context di tenant mana pun.
$this->assertNull(TenantContext::fromUser($this->pdo, 4, 'toko-bunga-melati'));
$this->assertNull(TenantContext::fromUser($this->pdo, 4, 'studio-foto-cendrawasih'));
}
public function testProyekTenantSatuTidakTerlihatDariKonteksTenantDua(): void
{
// Dewi berangkat sebagai member tenant #1.
TenantContext::set(TenantContext::fromUser($this->pdo, 3, 'toko-bunga-melati'));
$dariToko = $this->namaProyekDiContext();
// Lalu pindah context ke tenant #2 sebagai admin.
TenantContext::clear();
TenantContext::set(TenantContext::fromUser($this->pdo, 3, 'studio-foto-cendrawasih'));
$dariStudio = $this->namaProyekDiContext();
$this->assertSame(['Website Baru', 'Kampanye Valentine'], $dariToko);
$this->assertSame(
['Fotografi Produk Sabun', 'Branding Ulang 2026', 'Katalog Musim Hujan'],
$dariStudio
);
$this->assertSame([], array_intersect($dariToko, $dariStudio));
}
public function testPengambilanProyekLintasTenantMengembalikanNull(): void
{
// Syarat ganda id + tenant_id: proyek #3 milik tenant #2
// tidak bisa diambil dengan konteks tenant #1, dan sebaliknya.
$this->assertNull($this->proyekUntukTenant(3, 1));
$this->assertNull($this->proyekUntukTenant(1, 2));
$this->assertNull($this->proyekUntukTenant(99, 1));
$milikStudio = $this->proyekUntukTenant(3, 2);
$this->assertNotNull($milikStudio);
$this->assertSame('Fotografi Produk Sabun', $milikStudio['nama']);
}
public function testKeanggotaanLintasTenantTidakDitemukan(): void
{
$this->assertNull($this->memberships->findByUserAndTenant(1, 2));
$this->assertNull($this->memberships->findByUserAndTenant(2, 1));
$this->assertNull($this->memberships->findByUserAndTenant(4, 1));
$this->assertSame('member', $this->memberships->findByUserAndTenant(3, 1)['role']);
$this->assertSame('admin', $this->memberships->findByUserAndTenant(3, 2)['role']);
}
public function testAccessControlMenolakAksiLintasTenant(): void
{
// Pemilik tenant #1 kehilangan seluruh kekuasaannya di
// tenant #2; rudi ditolak di mana-mana; di dalam tenant
// sendiri izin tetap berjalan normal.
$this->assertFalse($this->access->can(1, 'project.view', 2));
$this->assertFalse($this->access->can(2, 'project.view', 1));
$this->assertFalse($this->access->can(4, 'project.view', 1));
$this->assertFalse($this->access->can(4, 'project.view', 2));
$this->assertTrue($this->access->can(1, 'project.view', 1));
$this->assertTrue($this->access->can(3, 'member.remove', 2));
}
public function testAksiTakDikenalDitolakUntukSemuaRole(): void
{
// Deny by default: aksi di luar matriks 8 aksi Bagian 3
// ditolak bahkan untuk owner sekalipun.
$this->assertFalse($this->access->can(1, 'project.rename', 1));
$this->assertFalse($this->access->can(3, 'project.rename', 2));
}
public function testRiwayatAuditTerbagiPerTenant(): void
{
// Dua tenant merekam peristiwa pada entitas bernomor sama -
// id 6 belum terpakai pada seed, jadi tidak perlu insert
// proyek sungguhan untuk menguji pembacaannya.
$this->audit->record(1, 1, 'project.create', 'project', 6,
'{"nama":"Landing Page Lebaran"}', $this->z('2026-02-02 09:05:00'));
$this->audit->record(2, 2, 'project.create', 'project', 6,
'{"nama":"Kampanye Promo Studio"}', $this->z('2026-02-02 10:00:00'));
$riwayatToko = $this->audit->riwayat(1, 'project', 6);
$riwayatStudio = $this->audit->riwayat(2, 'project', 6);
$this->assertCount(1, $riwayatToko);
$this->assertCount(1, $riwayatStudio);
$this->assertSame('{"nama":"Landing Page Lebaran"}', $riwayatToko[0]['detail']);
$this->assertSame('{"nama":"Kampanye Promo Studio"}', $riwayatStudio[0]['detail']);
}
public function testAuditLogMenolakTulisUlang(): void
{
$this->audit->record(1, 1, 'project.create', 'project', 1,
'{"nama":"Website Baru"}', $this->z('2026-02-02 09:00:00'));
try {
$this->pdo->exec('UPDATE audit_log SET aksi = \'project.rename\' WHERE id = 1');
$this->fail('UPDATE pada audit_log seharusnya ditolak trigger append-only.');
} catch (PDOException $e) {
$this->assertStringContainsString('append-only', $e->getMessage());
}
$this->expectException(PDOException::class);
$this->pdo->exec('DELETE FROM audit_log WHERE id = 1');
}
public function testBatasLanggananBerlakuPerTenant(): void
{
$now = $this->z('2026-02-01 09:00:00');
// Tenant #1 memegang 2 proyek: masih bisa membuat yang ketiga.
$this->assertTrue($this->subscriptions->bolehBuatProyek(1, $now));
$this->tambahProyek(1, 'Landing Page Lebaran');
$this->assertFalse($this->subscriptions->bolehBuatProyek(1, $now));
$this->assertSame(
'Batas plan Gratis: 3 proyek (saat ini 3).',
$this->subscriptions->alasanBlokir(1, $now)
);
// Tenant #2 memegang 3 proyek sejak seed: penuh sejak awal.
$this->assertFalse($this->subscriptions->bolehBuatProyek(2, $now));
// Budi meng-upgrade tenant #1 ke plan Pro: batas naik ke 25
// untuk tenant #1 saja - tenant #2 tidak ikut terangkat.
$this->subscriptions->subscribe(1, 'pro', $now);
$this->assertSame(25, $this->subscriptions->batasProyekEfektif(1, $now));
$this->assertTrue($this->subscriptions->bolehBuatProyek(1, $now));
$this->assertSame(3, $this->subscriptions->batasProyekEfektif(2, $now));
$this->assertSame('tidak-berlangganan', $this->subscriptions->status(2, $now));
}
public function testTenggangHanyaMengunciTenantYangBerlangganan(): void
{
// Tenant #1 berlangganan Pro 2026-02-01, berakhir 2026-03-03.
$this->subscriptions->subscribe(1, 'pro', $this->z('2026-02-01 09:00:00'));
// Dua hari setelah masa berlaku habis: tenant #1 tenggang,
// tenant #2 sama sekali tidak tersentuh status itu.
$tenggang = $this->z('2026-03-05 09:00:00');
$this->assertSame('tenggang', $this->subscriptions->status(1, $tenggang));
$this->assertFalse($this->subscriptions->bolehBuatProyek(1, $tenggang));
$this->assertStringContainsString('Masa tenggang', $this->subscriptions->alasanBlokir(1, $tenggang));
$this->assertSame('tidak-berlangganan', $this->subscriptions->status(2, $tenggang));
$this->assertSame(
'Batas plan Gratis: 3 proyek (saat ini 3).',
$this->subscriptions->alasanBlokir(2, $tenggang)
);
}
public function testJobMembawaKonteksTenantSepanjangSiklus(): void
{
$now = $this->z('2026-02-02 08:00:00');
$this->queue->enqueue(1, 'email.laporan-mingguan',
'{"tenant_id":1,"email":"[email protected]"}', $now);
$this->queue->enqueue(2, 'subscription.sweep',
'{"tenant_id":2,"cek_pada":"2026-02-02 08:00:00"}', $now);
$jobPertama = $this->queue->claim($now);
$this->assertNotNull($jobPertama);
$this->assertSame(1, (int) $jobPertama['tenant_id']);
$this->assertSame(1, json_decode((string) $jobPertama['payload'], true)['tenant_id']);
$this->queue->complete((int) $jobPertama['id']);
$jobKedua = $this->queue->claim($now);
$this->assertNotNull($jobKedua);
$this->assertSame(2, (int) $jobKedua['tenant_id']);
$this->assertSame(2, json_decode((string) $jobKedua['payload'], true)['tenant_id']);
$this->queue->complete((int) $jobKedua['id']);
$this->assertSame([], $this->queue->deadLetters());
}
public function testJobGagalTigaKaliMenjadiDeadLetterDenganTenant(): void
{
$now = $this->z('2026-02-02 08:00:00');
$job = $this->queue->enqueue(2, 'email.laporan-mingguan',
'{"tenant_id":2,"email":"[email protected]"}', $now);
$id = (int) $job['id'];
// Tiga kali claim-then-fail: claim menaikkan attempts dan
// mengubah status menjadi diproses, fail menjadwalkan ulang
// dengan backoff 2 lalu 4 menit, dan percobaan ketiga
// (attempts = MAX_ATTEMPTS) membuat job mati permanen.
$this->assertNotNull($this->queue->claim($now));
$this->assertSame('menunggu', $this->queue->fail($id, 'SMTP mati', $now));
$this->assertNotNull($this->queue->claim($this->z('2026-02-02 08:02:00')));
$this->assertSame('menunggu', $this->queue->fail($id, 'SMTP mati', $this->z('2026-02-02 08:02:00')));
$jobKetiga = $this->queue->claim($this->z('2026-02-02 08:06:00'));
$this->assertNotNull($jobKetiga);
$this->assertSame(3, (int) $jobKetiga['attempts']);
$this->assertSame('gagal', $this->queue->fail($id, 'SMTP mati', $this->z('2026-02-02 08:06:00')));
$dead = $this->queue->deadLetters();
$this->assertCount(1, $dead);
$this->assertSame($id, (int) $dead[0]['id']);
$this->assertSame(2, (int) $dead[0]['tenant_id']);
$this->assertSame(3, (int) $dead[0]['attempts']);
$this->assertSame('SMTP mati', $dead[0]['error']);
}
/**
* Test ini diletakkan SETELAH test-test yang memasang context,
* dan bergantung pada urutan deklarasi (default PHPUnit): bila
* tearDown() lupa memanggil clear(), context test sebelumnya
* masih terpasang dan get() tidak melempar - test ini gagal.
*/
public function testContextDimulaiKosongDiTestBerikutnya(): void
{
$this->expectException(RuntimeException::class);
TenantContext::get();
}
/**
* Jam beku WIB - pola yang sama dengan SubscriptionService.
*/
private function z(string $waktu): DateTimeImmutable
{
return new DateTimeImmutable($waktu, new DateTimeZone('Asia/Jakarta'));
}
private function hashUser(int $userId): string
{
$stmt = $this->pdo->prepare('SELECT password_hash FROM users WHERE id = :id');
$stmt->execute(['id' => $userId]);
return (string) $stmt->fetchColumn();
}
/**
* Daftar nama proyek yang terbaca dari context aktif.
*
* Query mengikuti pola repository Bagian 2: tenant_id selalu
* berasal dari TenantContext, bukan dari parameter request.
*
* @return list<string>
*/
private function namaProyekDiContext(): array
{
$stmt = $this->pdo->prepare(
'SELECT nama FROM projects WHERE tenant_id = :tenant_id ORDER BY id'
);
$stmt->execute(['tenant_id' => TenantContext::get()->tenantId]);
return array_map(
fn (array $baris): string => (string) $baris['nama'],
$stmt->fetchAll(PDO::FETCH_ASSOC),
);
}
/**
* Ambil satu proyek dengan syarat ganda id + tenant_id -
* pola findForTenant() Bagian 2 yang menolak IDOR lintas tenant.
*
* @return array<string, mixed>|null
*/
private function proyekUntukTenant(int $projectId, int $tenantId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, tenant_id, nama FROM projects
WHERE id = :id AND tenant_id = :tenant_id'
);
$stmt->execute(['id' => $projectId, 'tenant_id' => $tenantId]);
$baris = $stmt->fetch(PDO::FETCH_ASSOC);
return $baris === false ? null : $baris;
}
private function tambahProyek(int $tenantId, string $nama): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO projects (tenant_id, nama) VALUES (:tenant_id, :nama)'
);
$stmt->execute(['tenant_id' => $tenantId, 'nama' => $nama]);
}
}