Filter Status yang Tidak Dikenal Harus Ditolak, Bukan Diabaikan
Filter status yang nilainya tak dikenal sempat mengembalikan semua baris hidup dengan 200. Kini endpoint itu menolaknya dengan 400 VALIDATION.
Ringkasan
Jadi ternyata API-nya nge-drop filter status kalau nilainya nggak dikenal, alias ngasih semua data dengan 200 OK, dan tesnya malah ngunci perilaku salah itu. Penulis ganti ke switch fail-closed: string kosong boleh (tab semua), nilai whitelist lolos, sisanya error ErrValidation jadi 400. Pelajarannya: tes hijau belum tentu kontraknya bener, sesekali baca ulang assertion yang aneh.
Saya membuka hasil tes TestRepository_ListFilters di repositori KotaPortal dan melihat sesuatu yang janggal. Ketika parameter status=bogus dikirim ke endpoint daftar entitas, tes itu justru mengklaim total = 3. Artinya semua baris hidup dikembalikan dengan 200 OK, tanpa jejak parameter itu sama sekali.
Perilaku ini sering saya dengar disebut “filtering yang toleran”. Dugaan saya waktu itu: kode lama memang sengaja longgar terhadap input di luar skema demi menjaga kompatibilitas klien lama.
Ternyata bukan toleransi. Ini filter-dropping: nilai status yang tidak dikenali diperlakukan persis seperti tidak ada filter sama sekali. Ketikan salah pada parameter tidak menghasilkan error, melainkan hasil yang salah. Dan yang paling mengganggu, suite tes justru mengabadikan perilaku fail-open itu sebagai kondisi yang “benar” lewat assertion total = 3 untuk status=bogus. Tesnya hijau, kontraknya bocor.
Keputusan untuk Fail-Closed
Saya mengganti pemeriksaan if yang longgar dengan switch yang eksplisit: satu lengan untuk nilai kosong, satu lengan untuk anggota whitelist, dan default yang menolak.
switch q.Status {
case "":
// "" = tanpa filter status; ini kontrak tab "semua" di frontend
case "draft", "published", "scheduled":
where += " AND e.status = ?"
args = append(args, q.Status)
default:
return nil, 0, fmt.Errorf(
"%w: status harus draft, published, atau scheduled",
ErrValidation,
)
}
Perubahan ini menjadikan API fail-closed, tapi tidak membakar kontrak yang sudah ada. String kosong adalah nilai yang bermakna: frontend punya tab “semua” yang memang mengirim status= kosong, jadi keketatannya hidup di lengan default, bukan di “tolak semua yang bukan anggota whitelist”. Nilai “tidak ada” dan nilai “tidak valid” adalah dua keadaan berbeda, dan kodenya sekarang membedakan keduanya secara eksplisit.
Sebelum membalik perilaku ini, saya memetakan radius ledakannya. Service.List adalah satu-satunya pemanggil produksi dari repository List, dan Handler.list satu-satunya pemanggil Service.List, sehingga keputusan ini berdiri di satu titik. Biaya kompatibilitasnya juga saya hitung: klien yang dulu mengirim status salah eja masih menerima 200 berisi seluruh data, kini menerima 400. Itu memang disengaja. Daftar yang keliru lebih mahal daripada error yang cepat, karena kesalahan seperti ini baru ketahuan saat seseorang sudah salah membaca hasilnya.
Tesnya saya balik dari merah ke hijau. Assertion lama yang mengunci total = 3 untuk status=bogus diganti dengan errors.Is(err, ErrValidation), ditambah dua jangkar baru: List(ListQuery{}) tetap mengembalikan semua baris hidup, dan status=scheduled tetap menyaring (barisnya nol karena satu-satunya scheduled ada di tempat sampah). Error yang dikembalikan memakai sentinel ErrValidation yang sudah ada, jadi handler cukup memetakannya ke 400 VALIDATION tanpa jalur error baru.
Standar Industri Sebenarnya Mengarah ke Sana
Mengabaikan input diam-diam berarti API berbohong tentang apa yang dipahaminya, dan panduan resmi berpihak pada penolakan eksplisit.
Google AIP-160 menyatakan bahwa field values untuk tipe data terbatas seperti enum dalam filter “must be a valid value in the set”, dan filter yang tidak patuh pada skema seharusnya menghasilkan error INVALID_ARGUMENT [1]. Panduan REST Azure lebih tegas lagi: validasi semua nilai query parameter dan header, dan gagalkan operasi dengan 400 Bad Request begitu satu nilai gagal validasi, lengkap dengan pesan yang menjelaskan apa yang salah agar pelanggan bisa memperbaikinya sendiri [2].
OWASP Input Validation Cheat Sheet menyarankan hal serupa dari sisi keamanan: tentukan apa yang diterima aplikasi, lalu tolak nilai di luar aturan itu, alih-alih mencoba mengenali satu per satu string berbahaya [3]. Dan menurut RFC 9110, 400 Bad Request memang kode untuk “the server cannot or will not process the request due to something that is perceived to be a client error” [4]. Status bogus adalah contoh persis dari kategori itu.
Sisi Lain yang Justru Harus Toleran
Yang sering ketuker adalah arah toleransinya. Panduan Azure membedakan dua hal: server “may return” nilai enum yang tidak didefinisikan untuk versi API yang diminta, tetapi “should not accept” nilai enum di luar definisi versi API itu dari permintaan [2]. Pembaca boleh longgar, penulis harus ketat. Respons yang memuat nilai status baru dari server versi lebih baru masih bisa dibaca klien lama; filter yang mengirim nilai asing ke server bukanlah kasus yang sama, karena di situ yang salah justru pemahaman klien tentang domain nilai.
Dari sini saya menutup pelajaran yang paling saya pegang: tes yang hijau tidak menjamin kontrak yang benar. Assertion bisa saja mengabadikan keputusan lama yang keliru, dan cara mengetahuinya adalah membaca ulang tes yang mengunci perilaku aneh, lalu membalikkannya menjadi merah sebelum memperbaiki kodenya.