This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2026-05-10 21:40:06 +07:00
parent 79ebd33d14
commit 2366eb1119
7 changed files with 1035 additions and 14 deletions

View File

@ -1,3 +1,7 @@
import org.gradle.kotlin.dsl.androidTestImplementation
import org.gradle.kotlin.dsl.debugImplementation
import org.gradle.kotlin.dsl.testImplementation
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@ -68,6 +72,13 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.4.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
// CameraX (Pastikan Anda sudah punya ini) // CameraX (Pastikan Anda sudah punya ini)
implementation("androidx.camera:camera-core:1.3.0") implementation("androidx.camera:camera-core:1.3.0")
implementation("androidx.camera:camera-camera2:1.3.0") implementation("androidx.camera:camera-camera2:1.3.0")

View File

@ -0,0 +1,299 @@
package id.ac.ubharajaya.sistemakademik
import android.util.Base64
import kotlin.math.*
// ================================================================
// AbsensiUtils.kt
// Letakkan file ini di:
// app/src/main/java/com/ubhara/absensi/AbsensiUtils.kt
//
// Berisi semua fungsi murni (pure functions) yang bisa diuji
// tanpa memerlukan Android Context / Network.
// ================================================================
object AbsensiUtils {
// ==================== KONSTANTA ====================
const val KAMPUS_LATITUDE = -6.223325
const val KAMPUS_LONGITUDE = 107.009406
const val RADIUS_METER = 500.0
const val LOCATION_TOKEN_TTL_MS = 120_000L // 2 menit dalam millisecond
const val MAX_LOGIN_ATTEMPTS = 5
const val RATE_LIMIT_WINDOW_MS = 60_000L // 1 menit
// ==================== GPS — KALKULASI JARAK ====================
/**
* Hitung jarak dua titik koordinat GPS menggunakan Haversine formula.
* Mengembalikan jarak dalam satuan meter.
*/
fun hitungJarakMeter(
lat1: Double, lon1: Double,
lat2: Double, lon2: Double
): Double {
val R = 6371000.0
val phi1 = Math.toRadians(lat1)
val phi2 = Math.toRadians(lat2)
val dPhi = Math.toRadians(lat2 - lat1)
val dLambda = Math.toRadians(lon2 - lon1)
val a = sin(dPhi / 2).pow(2) +
cos(phi1) * cos(phi2) * sin(dLambda / 2).pow(2)
return R * 2 * atan2(sqrt(a), sqrt(1 - a))
}
/**
* Cek apakah koordinat berada dalam radius kampus.
*/
fun dalamRadiusKampus(lat: Double, lon: Double): Boolean {
return hitungJarakMeter(lat, lon, KAMPUS_LATITUDE, KAMPUS_LONGITUDE) <= RADIUS_METER
}
// ==================== GPS — DETEKSI ANOMALI (FIX 8) ====================
/**
* Deteksi koordinat yang mencurigakan / kemungkinan dimanipulasi.
* Return: Pair(isMencurigakan, alasan)
*/
fun deteksiAnomaliKoordinat(lat: Double, lon: Double): Pair<Boolean, String> {
// 1. Koordinat null island (GPS tidak aktif)
if (lat == 0.0 && lon == 0.0)
return Pair(true, "GPS tidak aktif atau koordinat tidak valid (0,0)")
// 2. Koordinat persis sama dengan konstanta titik kampus
if (lat == KAMPUS_LATITUDE && lon == KAMPUS_LONGITUDE)
return Pair(true, "Koordinat identik dengan titik kampus, kemungkinan diinput manual")
// 3. Presisi desimal terlalu sedikit (GPS asli selalu ≥4 desimal)
val latDesimal = lat.toString().substringAfter(".").trimEnd('0').length
val lonDesimal = lon.toString().substringAfter(".").trimEnd('0').length
if (latDesimal < 4 || lonDesimal < 4)
return Pair(true, "Presisi koordinat terlalu rendah ($latDesimal/$lonDesimal desimal), bukan dari GPS asli")
// 4. Di luar batas geografis Indonesia
if (lat < -11.0 || lat > 6.0 || lon < 95.0 || lon > 141.0)
return Pair(true, "Koordinat di luar wilayah Indonesia")
return Pair(false, "OK")
}
// ==================== VALIDASI FOTO (FIX 2) ====================
/**
* Validasi string base64 foto:
* - Tidak boleh kosong
* - Harus valid base64
* - Harus file gambar (JPG/PNG/WEBP berdasarkan magic bytes)
* - Ukuran minimum 5KB
*
* Catatan: Fungsi ini menggunakan implementasi Base64 murni tanpa
* android.util.Base64 agar bisa ditest di JVM (Local Unit Test).
* Return: Pair(isValid, pesan)
*/
// ==================== VALIDASI FOTO (FIX 2) ====================
fun validasiFoto(fotoBase64: String?): Pair<Boolean, String> {
if (fotoBase64.isNullOrBlank())
return Pair(false, "Foto wajib disertakan")
// Hapus prefix data URL jika ada (misal: "data:image/jpeg;base64,")
val raw = if (fotoBase64.contains(",")) fotoBase64.substringAfter(",") else fotoBase64
val decoded: ByteArray = try {
java.util.Base64.getDecoder().decode(raw)
} catch (e: Exception) {
return Pair(false, "Format foto tidak valid")
}
// Magic bytes check
val isJpg = decoded.size >= 3 && decoded[0] == 0xFF.toByte() && decoded[1] == 0xD8.toByte() && decoded[2] == 0xFF.toByte()
// PERBAIKAN DI SINI: Tambahkan .toByte() pada setiap elemen di dalam byteArrayOf
val isPng = decoded.size >= 8 && decoded.sliceArray(0..7).contentEquals(
byteArrayOf(
0x89.toByte(), 0x50.toByte(), 0x4E.toByte(), 0x47.toByte(),
0x0D.toByte(), 0x0A.toByte(), 0x1A.toByte(), 0x0A.toByte()
)
)
val isWebp = decoded.size >= 12 && String(decoded.sliceArray(8..11)) == "WEBP"
if (!isJpg && !isPng && !isWebp)
return Pair(false, "File bukan gambar yang valid (harus JPG/PNG/WEBP)")
// Minimum 5KB
if (decoded.size < 5 * 1024)
return Pair(false, "Ukuran foto terlalu kecil, pastikan foto wajah terambil dengan benar")
return Pair(true, "OK")
}
// ==================== VALIDASI FORM ====================
/**
* Validasi NPM mahasiswa.
* Return: Pair(isValid, pesanError)
*/
fun validasiNpm(npm: String): Pair<Boolean, String> {
if (npm.isBlank())
return Pair(false, "NPM tidak boleh kosong")
if (!npm.all { it.isDigit() })
return Pair(false, "NPM harus berupa angka")
if (npm.length < 9 || npm.length > 15)
return Pair(false, "Panjang NPM tidak valid (9-15 digit)")
return Pair(true, "OK")
}
fun validasiPassword(password: String): Pair<Boolean, String> {
if (password.length < 6)
return Pair(false, "Password minimal 6 karakter")
return Pair(true, "OK")
}
fun validasiNama(nama: String): Pair<Boolean, String> {
if (nama.isBlank())
return Pair(false, "Nama tidak boleh kosong")
if (nama.trim().length < 3)
return Pair(false, "Nama terlalu pendek (minimal 3 karakter)")
return Pair(true, "OK")
}
fun validasiSemester(semester: Int): Pair<Boolean, String> {
if (semester < 1 || semester > 14)
return Pair(false, "Semester tidak valid (1-14)")
return Pair(true, "OK")
}
fun validasiDeviceId(deviceId: String): Pair<Boolean, String> {
if (deviceId.isBlank())
return Pair(false, "Field device_id wajib diisi")
return Pair(true, "OK")
}
/**
* Validasi semua field registrasi sekaligus.
* Return: Pair(isValid, pesanError) mengembalikan error pertama yang ditemukan.
*/
fun validasiFormRegistrasi(
npm: String, password: String, nama: String,
semester: Int, deviceId: String
): Pair<Boolean, String> {
validasiNpm(npm).let { if (!it.first) return it }
validasiPassword(password).let { if (!it.first) return it }
validasiNama(nama).let { if (!it.first) return it }
validasiSemester(semester).let { if (!it.first) return it }
validasiDeviceId(deviceId).let { if (!it.first) return it }
return Pair(true, "OK")
}
// ==================== SIMULASI DEVICE BINDING (FIX 3) ====================
/**
* Simulasi pengecekan device binding di sisi client.
* Di production, validasi ini dilakukan oleh server.
* deviceIdTerdaftar = device_id yang tersimpan di SharedPreferences / server
* deviceIdSekarang = device_id HP yang sedang login
*/
fun cekDeviceBinding(
deviceIdTerdaftar: String?,
deviceIdSekarang: String
): Pair<Boolean, String> {
if (deviceIdTerdaftar.isNullOrBlank())
return Pair(true, "Belum ada device terdaftar, login pertama kali")
if (deviceIdTerdaftar == deviceIdSekarang)
return Pair(true, "Device sama, login diizinkan")
return Pair(false, "Akun ini sudah terdaftar di perangkat lain. Hubungi admin untuk reset.")
}
// ==================== SIMULASI LOCATION TOKEN (FIX 7) ====================
data class LocationToken(
val token: String,
val idMahasiswa: Int,
val deviceId: String,
val lat: Double,
val lon: Double,
val jarakMeter: Double,
val createdAtMs: Long // System.currentTimeMillis() saat token dibuat
)
/**
* Validasi apakah location token masih berlaku.
* Return: Pair(isValid, pesanError)
*/
fun validasiLocationToken(
token: LocationToken?,
idMahasiswaLogin: Int,
deviceIdLogin: String,
nowMs: Long = System.currentTimeMillis()
): Pair<Boolean, String> {
if (token == null)
return Pair(false, "Location token tidak valid atau sudah digunakan")
if (nowMs - token.createdAtMs > LOCATION_TOKEN_TTL_MS)
return Pair(false, "Location token sudah kadaluarsa (maks 120 detik). Silakan ulangi proses absensi.")
if (token.idMahasiswa != idMahasiswaLogin)
return Pair(false, "Location token bukan milik Anda")
if (token.deviceId != deviceIdLogin)
return Pair(false, "Location token digunakan dari perangkat berbeda")
return Pair(true, "OK")
}
// ==================== SIMULASI RATE LIMITING (FIX 4) ====================
/**
* Cek apakah sudah melebihi batas percobaan dalam window waktu tertentu.
* timestamps = daftar waktu percobaan sebelumnya (dalam ms)
* nowMs = waktu sekarang (ms)
*/
fun cekRateLimit(
timestamps: List<Long>,
maxPercobaan: Int = MAX_LOGIN_ATTEMPTS,
windowMs: Long = RATE_LIMIT_WINDOW_MS,
nowMs: Long = System.currentTimeMillis()
): Boolean {
val dalamWindow = timestamps.count { nowMs - it < windowMs }
return dalamWindow >= maxPercobaan
}
// ==================== VALIDASI JADWAL (FIX 5) ====================
/**
* Cek apakah jadwal yang dipilih sesuai dengan jurusan & semester mahasiswa.
*/
fun cekKepemilikanJadwal(
jurusanMahasiswa: String,
semesterMahasiswa: Int,
jurusanJadwal: String,
semesterJadwal: Int
): Pair<Boolean, String> {
if (jurusanMahasiswa.trim().lowercase() != jurusanJadwal.trim().lowercase())
return Pair(false, "Jadwal tidak valid atau bukan milik kelas Anda")
if (semesterMahasiswa != semesterJadwal)
return Pair(false, "Jadwal tidak valid atau bukan milik kelas Anda")
return Pair(true, "OK")
}
// ==================== VALIDASI JAM KELAS (FIX 6) ====================
/**
* Cek apakah waktu sekarang berada dalam rentang jam kelas.
* jamMulai / jamSelesai dalam format "HH:MM"
* jamSekarang dalam format "HH:MM"
*/
fun cekJamKelasAktif(
jamMulai: String,
jamSelesai: String,
jamSekarang: String
): Pair<Boolean, String> {
fun parse(s: String): Int {
val parts = s.split(":")
return parts[0].toInt() * 60 + parts[1].toInt()
}
val mulai = parse(jamMulai)
val selesai = parse(jamSelesai)
val sekarang = parse(jamSekarang)
if (sekarang < mulai || sekarang > selesai)
return Pair(false, "Absensi hanya bisa dilakukan saat jam kelas aktif ($jamMulai - $jamSelesai)")
return Pair(true, "OK")
}
}

View File

@ -88,14 +88,14 @@ import androidx.compose.ui.unit.sp
object AppConstants { object AppConstants {
// Backend API URL - GANTI SESUAI SERVER ANDA // Backend API URL - GANTI SESUAI SERVER ANDA
// const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android // const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android
const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik const val BASE_URL = "http://192.168.000.000:5000" // Untuk device fisik
// // Koordinat Kampus (UBHARA Jaya) // // Koordinat Kampus (UBHARA Jaya)
// const val KAMPUS_LATITUDE = -6.223325 const val KAMPUS_LATITUDE = -6.223325
// const val KAMPUS_LONGITUDE = 107.009406 const val KAMPUS_LONGITUDE = 107.009406
// Koordinat Device Saat ini (Untuk Testing) // Koordinat Device Saat ini (Untuk Testing)
const val KAMPUS_LATITUDE = -6.2396008 // const val KAMPUS_LATITUDE = -6.2394664
const val KAMPUS_LONGITUDE = 107.0893571 // const val KAMPUS_LONGITUDE = 107.0898995
const val RADIUS_METER = 500.0 const val RADIUS_METER = 500.0

View File

@ -0,0 +1,572 @@
package id.ac.ubharajaya.sistemakademik
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
// ================================================================
// AbsensiUtilsTest.kt
// Letakkan file ini di:
// app/src/test/java/com/ubhara/absensi/AbsensiUtilsTest.kt
//
// 22 Unit Test sesuai dokumen Unit Testing Aplikasi Absensi
// UBHARA Jaya — 10 Mei 2026
// ================================================================
@RunWith(JUnit4::class)
class AbsensiUtilsTest {
// ============================================================
// DATA KONSTANTA TEST
// ============================================================
private val NPM_VALID = "202310715297"
private val PASSWORD_VALID = "Password123"
private val NAMA_VALID = "Ariq Dwi Saputra"
private val DEVICE_TERDAFTAR = "abc123device"
private val DEVICE_LAIN = "device-orang-lain-999"
// Koordinat dalam radius kampus (~66m dari titik kampus)
private val LAT_DALAM = -6.223401
private val LON_DALAM = 107.009512
// Koordinat di luar radius kampus (~1.9km dari kampus)
private val LAT_LUAR = -6.235000
private val LON_LUAR = 107.021000
// Base64 gambar JPG valid berukuran >5KB
// (representasi minimal JPG valid 1x1 pixel merah berukuran ~630 byte untuk test format,
// gunakan foto nyata >5KB untuk test ukuran)
private val FOTO_JPG_VALID_HEADER = "/9j/4AAQ" // prefix JPG base64
private val FOTO_BUKAN_GAMBAR = "aGVsbG8gd29ybGQ=" // "hello world" di base64
private val FOTO_PNG_HEADER = "iVBORw0KGgo=" // prefix PNG base64
// ============================================================
// MODUL AUTENTIKASI — REGISTRASI
// ============================================================
/**
* UT-001
* Validasi form registrasi dengan data lengkap dan valid
* Tipe: Black-Box Testing
*/
@Test
fun ut001_registrasi_data_lengkap_valid_harus_lulus() {
val (valid, pesan) = AbsensiUtils.validasiFormRegistrasi(
npm = NPM_VALID,
password = PASSWORD_VALID,
nama = NAMA_VALID,
semester = 6,
deviceId = DEVICE_TERDAFTAR
)
assertTrue("UT-001 GAGAL — Registrasi valid seharusnya diterima: $pesan", valid)
assertEquals("OK", pesan)
}
/**
* UT-002
* Registrasi dengan NPM yang sudah terdaftar (duplikat)
* Di unit test, disimulasikan dengan cek format NPM yang sama
* Tipe: Black-Box Testing
*/
@Test
fun ut002_registrasi_npm_duplikat_harus_ditolak() {
// Simulasi: daftar NPM yang sudah ada di "database lokal"
val npmSudahTerdaftar = setOf("202310715297", "202310715001")
val npmBaru = "202310715297" // sama dengan yang sudah ada
val sudahAda = npmSudahTerdaftar.contains(npmBaru)
assertTrue("UT-002 GAGAL — NPM duplikat seharusnya terdeteksi", sudahAda)
}
/**
* UT-003
* Registrasi dengan field wajib tidak diisi (device_id kosong)
* Tipe: Black-Box Testing
*/
@Test
fun ut003_registrasi_device_id_kosong_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiFormRegistrasi(
npm = NPM_VALID,
password = PASSWORD_VALID,
nama = NAMA_VALID,
semester = 6,
deviceId = "" // ← kosong
)
assertFalse("UT-003 GAGAL — device_id kosong seharusnya ditolak", valid)
assertTrue("UT-003 GAGAL — Pesan error harus menyebut device_id: $pesan",
pesan.contains("device_id", ignoreCase = true))
}
// ============================================================
// MODUL AUTENTIKASI — LOGIN
// ============================================================
/**
* UT-004
* Validasi data login dengan NPM dan password valid
* Tipe: Black-Box Testing
*/
@Test
fun ut004_login_npm_dan_password_valid_harus_diterima() {
val (npmValid, _) = AbsensiUtils.validasiNpm(NPM_VALID)
val (passValid, _) = AbsensiUtils.validasiPassword(PASSWORD_VALID)
val (devValid, _) = AbsensiUtils.validasiDeviceId(DEVICE_TERDAFTAR)
assertTrue("UT-004 GAGAL — NPM valid seharusnya diterima", npmValid)
assertTrue("UT-004 GAGAL — Password valid seharusnya diterima", passValid)
assertTrue("UT-004 GAGAL — Device ID valid seharusnya diterima", devValid)
}
/**
* UT-005
* Login dengan password terlalu pendek (simulasi validasi sebelum kirim ke server)
* Tipe: Black-Box Testing
*/
@Test
fun ut005_login_password_terlalu_pendek_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiPassword("abc")
assertFalse("UT-005 GAGAL — Password pendek seharusnya ditolak", valid)
assertTrue("UT-005 GAGAL — Pesan harus menyebut '6': $pesan",
pesan.contains("6"))
}
/**
* UT-006
* Login dari perangkat berbeda setelah device sudah terdaftar (Device Binding FIX 3)
* Tipe: Black-Box Testing
*/
@Test
fun ut006_login_device_berbeda_harus_ditolak() {
val (diizinkan, pesan) = AbsensiUtils.cekDeviceBinding(
deviceIdTerdaftar = DEVICE_TERDAFTAR,
deviceIdSekarang = DEVICE_LAIN // berbeda!
)
assertFalse("UT-006 GAGAL — Device berbeda seharusnya ditolak", diizinkan)
assertTrue("UT-006 GAGAL — Pesan harus menyebut 'perangkat lain': $pesan",
pesan.contains("perangkat lain", ignoreCase = true))
}
// ============================================================
// MODUL VALIDASI GPS — KALKULASI JARAK
// ============================================================
/**
* UT-007
* Koordinat dalam radius kampus (~66m) harus diterima
* Tipe: White-Box Testing
*/
@Test
fun ut007_koordinat_dalam_radius_kampus_harus_diterima() {
val jarak = AbsensiUtils.hitungJarakMeter(
LAT_DALAM, LON_DALAM,
AbsensiUtils.KAMPUS_LATITUDE, AbsensiUtils.KAMPUS_LONGITUDE
)
assertTrue(
"UT-007 GAGAL — Jarak $jarak meter seharusnya < ${AbsensiUtils.RADIUS_METER}m",
jarak < AbsensiUtils.RADIUS_METER
)
assertTrue(
"UT-007 GAGAL — dalamRadiusKampus() harus true",
AbsensiUtils.dalamRadiusKampus(LAT_DALAM, LON_DALAM)
)
}
/**
* UT-008
* Koordinat di luar radius kampus (~1.9km) harus ditolak
* Tipe: White-Box Testing
*/
@Test
fun ut008_koordinat_luar_radius_kampus_harus_ditolak() {
val jarak = AbsensiUtils.hitungJarakMeter(
LAT_LUAR, LON_LUAR,
AbsensiUtils.KAMPUS_LATITUDE, AbsensiUtils.KAMPUS_LONGITUDE
)
assertTrue(
"UT-008 GAGAL — Jarak $jarak meter seharusnya > ${AbsensiUtils.RADIUS_METER}m",
jarak > AbsensiUtils.RADIUS_METER
)
assertFalse(
"UT-008 GAGAL — dalamRadiusKampus() harus false",
AbsensiUtils.dalamRadiusKampus(LAT_LUAR, LON_LUAR)
)
}
// ============================================================
// MODUL VALIDASI GPS — DETEKSI ANOMALI (FIX 8)
// ============================================================
/**
* UT-009
* Deteksi koordinat 0,0 GPS tidak aktif / null island
* Tipe: White-Box Testing
*/
@Test
fun ut009_koordinat_nol_nol_harus_terdeteksi_anomali() {
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(0.0, 0.0)
assertTrue("UT-009 GAGAL — Koordinat 0,0 seharusnya terdeteksi anomali", anomali)
assertTrue("UT-009 GAGAL — Pesan harus menyebut '0,0': $pesan",
pesan.contains("0,0"))
}
/**
* UT-010
* Deteksi koordinat identik dengan titik pusat kampus (copy-paste manual)
* Tipe: White-Box Testing
*/
@Test
fun ut010_koordinat_identik_titik_kampus_harus_terdeteksi_anomali() {
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(
AbsensiUtils.KAMPUS_LATITUDE,
AbsensiUtils.KAMPUS_LONGITUDE
)
assertTrue("UT-010 GAGAL — Koordinat identik kampus seharusnya terdeteksi", anomali)
assertTrue("UT-010 GAGAL — Pesan harus menyebut 'identik': $pesan",
pesan.contains("identik", ignoreCase = true))
}
/**
* UT-011
* Deteksi presisi koordinat terlalu rendah (bukan dari GPS hardware)
* GPS asli selalu menghasilkan 4 angka desimal
* Tipe: White-Box Testing
*/
@Test
fun ut011_koordinat_presisi_rendah_harus_terdeteksi_anomali() {
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(-6.22, 107.00)
assertTrue("UT-011 GAGAL — Presisi rendah seharusnya terdeteksi", anomali)
assertTrue("UT-011 GAGAL — Pesan harus menyebut 'presisi' atau 'desimal': $pesan",
pesan.contains("presisi", ignoreCase = true) ||
pesan.contains("desimal", ignoreCase = true))
}
/**
* UT-012
* Deteksi koordinat di luar wilayah geografis Indonesia
* Tipe: White-Box Testing
*/
@Test
fun ut012_koordinat_luar_indonesia_harus_terdeteksi_anomali() {
// Tokyo, Jepang
val (anomali, pesan) = AbsensiUtils.deteksiAnomaliKoordinat(35.676200, 139.650300)
assertTrue("UT-012 GAGAL — Koordinat luar Indonesia seharusnya terdeteksi", anomali)
assertTrue("UT-012 GAGAL — Pesan harus menyebut 'Indonesia': $pesan",
pesan.contains("Indonesia", ignoreCase = true))
}
// ============================================================
// MODUL VALIDASI FOTO (FIX 2)
// ============================================================
/**
* UT-013
* Submit absensi tanpa foto (null / kosong)
* Tipe: Black-Box Testing
*/
@Test
fun ut013_foto_null_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiFoto(null)
assertFalse("UT-013 GAGAL — Foto null seharusnya ditolak", valid)
assertTrue("UT-013 GAGAL — Pesan harus menyebut 'wajib': $pesan",
pesan.contains("wajib", ignoreCase = true))
}
@Test
fun ut013b_foto_string_kosong_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiFoto("")
assertFalse("UT-013b GAGAL — Foto kosong seharusnya ditolak", valid)
assertTrue(pesan.isNotBlank())
}
/**
* UT-014
* Submit dengan base64 bukan file gambar (teks random)
* "hello world" base64 bukan gambar
* Tipe: Black-Box Testing
*/
@Test
fun ut014_foto_bukan_gambar_harus_ditolak() {
// base64 dari string "hello world" — bukan file gambar
val (valid, pesan) = AbsensiUtils.validasiFoto(FOTO_BUKAN_GAMBAR)
assertFalse("UT-014 GAGAL — Bukan gambar seharusnya ditolak", valid)
assertTrue("UT-014 GAGAL — Pesan harus menyebut 'gambar': $pesan",
pesan.contains("gambar", ignoreCase = true) ||
pesan.contains("valid", ignoreCase = true))
}
/**
* UT-015
* Submit dengan gambar valid tapi berukuran < 5KB (gambar 1x1 pixel / dummy)
* Dibuat dari byte array minimal yang valid sebagai JPG
* Tipe: Black-Box Testing
*/
@Test
fun ut015_foto_terlalu_kecil_harus_ditolak() {
// Buat JPG minimal yang valid (magic bytes benar tapi ukuran < 5KB)
val jpgKecil = ByteArray(100).also { buf ->
buf[0] = 0xFF.toByte()
buf[1] = 0xD8.toByte()
buf[2] = 0xFF.toByte()
// Sisanya nol — valid JPG header tapi isi kosong
}
val base64Kecil = java.util.Base64.getEncoder().encodeToString(jpgKecil)
val (valid, pesan) = AbsensiUtils.validasiFoto(base64Kecil)
assertFalse("UT-015 GAGAL — Foto kecil seharusnya ditolak", valid)
assertTrue("UT-015 GAGAL — Pesan harus menyebut 'kecil' atau 'ukuran': $pesan",
pesan.contains("kecil", ignoreCase = true) ||
pesan.contains("ukuran", ignoreCase = true))
}
// ============================================================
// MODUL LOCATION TOKEN (FIX 7)
// ============================================================
/**
* UT-016
* Submit absensi tanpa location_token (null)
* Tipe: Black-Box Testing
*/
@Test
fun ut016_submit_tanpa_location_token_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.validasiLocationToken(
token = null, // tidak ada token
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR
)
assertFalse("UT-016 GAGAL — Tanpa token seharusnya ditolak", valid)
assertTrue("UT-016 GAGAL — Pesan harus menyebut 'tidak valid': $pesan",
pesan.contains("tidak valid", ignoreCase = true) ||
pesan.contains("digunakan", ignoreCase = true))
}
/**
* UT-017
* Submit dengan location_token palsu (tidak ada di server store)
* Disimulasikan dengan token = null karena tidak ditemukan di store
* Tipe: Black-Box Testing
*/
@Test
fun ut017_location_token_palsu_harus_ditolak() {
// Token palsu: tidak ditemukan di store → validasi terima null
val tokenStore: Map<String, AbsensiUtils.LocationToken> = emptyMap()
val tokenPalsu = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
val tokenData = tokenStore[tokenPalsu] // null karena tidak ada
val (valid, pesan) = AbsensiUtils.validasiLocationToken(
token = tokenData,
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR
)
assertFalse("UT-017 GAGAL — Token palsu seharusnya ditolak", valid)
}
/**
* UT-018
* Location token digunakan dua kali (one-time use)
* Setelah dipakai pertama kali, token dihapus dari store
* Tipe: Black-Box Testing
*/
@Test
fun ut018_location_token_digunakan_dua_kali_harus_ditolak() {
val nowMs = System.currentTimeMillis()
val tokenData = AbsensiUtils.LocationToken(
token = "valid-token-abc123",
idMahasiswa = 1,
deviceId = DEVICE_TERDAFTAR,
lat = LAT_DALAM,
lon = LON_DALAM,
jarakMeter = 66.0,
createdAtMs = nowMs
)
// Simulasi store token
val tokenStore = mutableMapOf("valid-token-abc123" to tokenData)
// Pemakaian PERTAMA — harus berhasil
val hasilPertama = AbsensiUtils.validasiLocationToken(
token = tokenStore["valid-token-abc123"],
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR,
nowMs = nowMs
)
assertTrue("UT-018 GAGAL — Pemakaian pertama seharusnya berhasil: ${hasilPertama.second}", hasilPertama.first)
// Hapus token dari store (one-time use)
tokenStore.remove("valid-token-abc123")
// Pemakaian KEDUA — harus gagal
val hasilKedua = AbsensiUtils.validasiLocationToken(
token = tokenStore["valid-token-abc123"], // null setelah dihapus
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR,
nowMs = nowMs
)
assertFalse("UT-018 GAGAL — Pemakaian kedua seharusnya ditolak", hasilKedua.first)
}
/**
* UT-019
* Location token yang sudah kadaluarsa (lebih dari 2 menit)
* Tipe: Black-Box Testing
*/
@Test
fun ut019_location_token_expired_harus_ditolak() {
val nowMs = System.currentTimeMillis()
// Token dibuat 121 detik yang lalu
val tokenExpired = AbsensiUtils.LocationToken(
token = "token-expired",
idMahasiswa = 1,
deviceId = DEVICE_TERDAFTAR,
lat = LAT_DALAM,
lon = LON_DALAM,
jarakMeter = 66.0,
createdAtMs = nowMs - 121_000L // 121 detik lalu
)
val (valid, pesan) = AbsensiUtils.validasiLocationToken(
token = tokenExpired,
idMahasiswaLogin = 1,
deviceIdLogin = DEVICE_TERDAFTAR,
nowMs = nowMs
)
assertFalse("UT-019 GAGAL — Token expired seharusnya ditolak", valid)
assertTrue("UT-019 GAGAL — Pesan harus menyebut 'kadaluarsa': $pesan",
pesan.contains("kadaluarsa", ignoreCase = true))
}
// ============================================================
// MODUL RATE LIMITING (FIX 4)
// ============================================================
/**
* UT-020
* Login gagal 6x dalam 1 menit harus diblokir pada percobaan ke-6
* Tipe: Black-Box Testing
*/
@Test
fun ut020_rate_limit_login_6x_harus_diblokir() {
val nowMs = System.currentTimeMillis()
// Simulasi 5 percobaan gagal dalam 1 menit terakhir
val timestamps = List(5) { nowMs - (it * 5000L) } // tiap 5 detik
// Percobaan ke 1-5: belum diblokir
val percobaan5 = AbsensiUtils.cekRateLimit(
timestamps = timestamps.take(4), // 4 percobaan sebelumnya
nowMs = nowMs
)
assertFalse("UT-020 GAGAL — Percobaan ke-5 belum seharusnya diblokir", percobaan5)
// Percobaan ke-6: harus diblokir
val percobaan6 = AbsensiUtils.cekRateLimit(
timestamps = timestamps, // sudah 5 percobaan
nowMs = nowMs
)
assertTrue("UT-020 GAGAL — Percobaan ke-6 seharusnya diblokir", percobaan6)
}
@Test
fun ut020b_rate_limit_setelah_1_menit_harus_reset() {
val nowMs = System.currentTimeMillis()
// 5 percobaan yang terjadi 61 detik lalu (sudah di luar window)
val timestampsLama = List(5) { nowMs - 61_000L - (it * 1000L) }
val diblokir = AbsensiUtils.cekRateLimit(
timestamps = timestampsLama,
nowMs = nowMs
)
assertFalse("UT-020b GAGAL — Setelah 1 menit seharusnya tidak diblokir lagi", diblokir)
}
// ============================================================
// MODUL VALIDASI JADWAL (FIX 5)
// ============================================================
/**
* UT-021
* Mahasiswa Teknik Informatika coba absen di jadwal Sistem Informasi
* Tipe: Black-Box Testing
*/
@Test
fun ut021_absen_jadwal_jurusan_lain_harus_ditolak() {
val (valid, pesan) = AbsensiUtils.cekKepemilikanJadwal(
jurusanMahasiswa = "Teknik Informatika",
semesterMahasiswa = 6,
jurusanJadwal = "Sistem Informasi", // jurusan berbeda
semesterJadwal = 6
)
assertFalse("UT-021 GAGAL — Jadwal jurusan lain seharusnya ditolak", valid)
assertTrue("UT-021 GAGAL — Pesan harus menyebut 'tidak valid' atau 'bukan milik': $pesan",
pesan.contains("tidak valid", ignoreCase = true) ||
pesan.contains("bukan milik", ignoreCase = true))
}
@Test
fun ut021b_absen_jadwal_semester_berbeda_harus_ditolak() {
val (valid, _) = AbsensiUtils.cekKepemilikanJadwal(
jurusanMahasiswa = "Teknik Informatika",
semesterMahasiswa = 6,
jurusanJadwal = "Teknik Informatika",
semesterJadwal = 4 // semester berbeda
)
assertFalse("UT-021b GAGAL — Semester berbeda seharusnya ditolak", valid)
}
@Test
fun ut021c_absen_jadwal_sendiri_harus_diterima() {
val (valid, _) = AbsensiUtils.cekKepemilikanJadwal(
jurusanMahasiswa = "Teknik Informatika",
semesterMahasiswa = 6,
jurusanJadwal = "Teknik Informatika",
semesterJadwal = 6
)
assertTrue("UT-021c GAGAL — Jadwal milik sendiri seharusnya diterima", valid)
}
// ============================================================
// MODUL VALIDASI JAM KELAS (FIX 6)
// ============================================================
/**
* UT-022
* Absensi di luar jam kelas aktif harus ditolak
* Tipe: Black-Box Testing
*/
@Test
fun ut022_absen_setelah_jam_selesai_harus_ditolak() {
// Jam kelas 08:00-10:30, absen pada 16:04
val (valid, pesan) = AbsensiUtils.cekJamKelasAktif(
jamMulai = "08:00",
jamSelesai = "10:30",
jamSekarang = "16:04"
)
assertFalse("UT-022 GAGAL — Absen setelah jam selesai seharusnya ditolak", valid)
assertTrue("UT-022 GAGAL — Pesan harus menyebut jam kelas: $pesan",
pesan.contains("08:00") && pesan.contains("10:30"))
}
@Test
fun ut022b_absen_sebelum_jam_mulai_harus_ditolak() {
// Jam kelas 13:00-15:30, absen pada 10:00
val (valid, pesan) = AbsensiUtils.cekJamKelasAktif(
jamMulai = "13:00",
jamSelesai = "15:30",
jamSekarang = "10:00"
)
assertFalse("UT-022b GAGAL — Absen sebelum jam mulai seharusnya ditolak", valid)
assertTrue(pesan.contains("13:00"))
}
@Test
fun ut022c_absen_saat_jam_kelas_aktif_harus_diterima() {
// Jam kelas 08:00-10:30, absen pada 09:15
val (valid, _) = AbsensiUtils.cekJamKelasAktif(
jamMulai = "08:00",
jamSelesai = "10:30",
jamSekarang = "09:15"
)
assertTrue("UT-022c GAGAL — Absen saat jam aktif seharusnya diterima", valid)
}
}

View File

@ -22,7 +22,7 @@ import mysql.connector
from mysql.connector import Error from mysql.connector import Error
import jwt import jwt
import bcrypt import bcrypt
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
from functools import wraps from functools import wraps
import base64 import base64
import math import math
@ -46,14 +46,15 @@ DB_CONFIG = {
SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this' SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this'
# Lokasi Kampus # Lokasi Kampus
# KAMPUS_LATITUDE = -6.223325 KAMPUS_LATITUDE = -6.223325
# KAMPUS_LONGITUDE = 107.009406 KAMPUS_LONGITUDE = 107.009406
# RADIUS_METER = 500.0 KAMPUS_LONGITUDE = 107.009406
RADIUS_METER = 500.0
# Testing # Testing
KAMPUS_LATITUDE = -6.2396008 # KAMPUS_LATITUDE = -6.2394664
KAMPUS_LONGITUDE = 107.0893571 # KAMPUS_LONGITUDE = 107.0898995
RADIUS_METER = 500.0 # RADIUS_METER = 500.0
# ==================== [FIX 7] LOCATION TOKEN STORE ==================== # ==================== [FIX 7] LOCATION TOKEN STORE ====================
# Format: { 'token_hex': { 'id_mahasiswa': int, 'lat': float, 'lon': float, 'expires_at': float } } # Format: { 'token_hex': { 'id_mahasiswa': int, 'lat': float, 'lon': float, 'expires_at': float } }
@ -311,7 +312,7 @@ def generate_token(id_mahasiswa, npm, device_id):
'id_mahasiswa': id_mahasiswa, 'id_mahasiswa': id_mahasiswa,
'npm': npm, 'npm': npm,
'device_id': device_id, 'device_id': device_id,
'exp': datetime.utcnow() + timedelta(days=30) 'exp': datetime.now(timezone.utc) + timedelta(days=30)
} }
return jwt.encode(payload, SECRET_KEY, algorithm='HS256') return jwt.encode(payload, SECRET_KEY, algorithm='HS256')

View File

@ -6,4 +6,5 @@ PyJWT==2.8.0
bcrypt==4.1.2 bcrypt==4.1.2
python-dotenv==1.0.0 python-dotenv==1.0.0
requests==2.31.0 requests==2.31.0
Flask-APScheduler==1.13.1 Flask-APScheduler==1.13.1
pytest==8.2.0

View File

@ -0,0 +1,137 @@
import pytest
import json
from unittest.mock import patch
import app as flask_app # Asumsi file utamamu bernama app.py
# ==========================================
# FIXTURE: Setup Lingkungan Testing
# ==========================================
@pytest.fixture
def setup_client():
# 1. Override nama database ke database khusus testing
flask_app.DB_CONFIG['database'] = 'db_absensi_test'
flask_app.init_database()
# 2. Buka koneksi dan Seed Data Dummy
conn = flask_app.get_db_connection()
cur = conn.cursor()
# Seed Mahasiswa
hashed_pw = flask_app.bcrypt.hashpw(b'Password123', flask_app.bcrypt.gensalt()).decode()
cur.execute("""
INSERT INTO mahasiswa (npm, password, nama, jenkel, fakultas, jurusan, semester, device_id)
VALUES ('202310715297', %s, 'Ariq', 'L', 'Fasilkom', 'Informatika', 6, 'device_sah_01')
""", (hashed_pw,))
# Seed Mata Kuliah
cur.execute("INSERT INTO mata_kuliah (kode_matkul, nama_matkul, sks, dosen) VALUES ('IF123', 'Integration Testing', 3, 'Tim QA')")
id_matkul = cur.lastrowid
# Seed Jadwal (Diset 00:00 - 23:59 hari ini agar [FIX 6] selalu lolos saat testing kapan pun)
hari_ini = flask_app.get_hari_indo()
cur.execute("""
INSERT INTO jadwal_kelas (id_matkul, hari, jam_mulai, jam_selesai, ruangan, jurusan, semester)
VALUES (%s, %s, '00:00:00', '23:59:59', 'Lab RPL', 'Informatika', 6)
""", (id_matkul, hari_ini))
id_jadwal = cur.lastrowid
conn.commit()
cur.close()
conn.close()
# 3. Jalankan Client Testing
with flask_app.app.test_client() as client:
yield client, id_jadwal
# 4. Teardown: Bersihkan/Drop database testing setelah selesai
conn = flask_app.get_db_connection()
cur = conn.cursor()
cur.execute("DROP DATABASE db_absensi_test")
cur.close()
conn.close()
# ==========================================
# SKENARIO INTEGRATION TESTING
# ==========================================
def test_it001_login_berhasil_dan_jwt_terbit(setup_client):
client, _ = setup_client
response = client.post('/api/auth/login', json={
"npm": "202310715297",
"password": "Password123",
"device_id": "device_sah_01"
})
data = json.loads(response.data)
assert response.status_code == 200
assert "token" in data['data']
assert data["message"] == "Login berhasil"
def test_it002_login_ditolak_device_berbeda(setup_client):
client, _ = setup_client
response = client.post('/api/auth/login', json={
"npm": "202310715297",
"password": "Password123",
"device_id": "device_ilegal_999" # [FIX 3] Memicu device binding error
})
assert response.status_code == 403
assert "terdaftar di perangkat lain" in json.loads(response.data)["error"]
def test_it003_request_location_token_berhasil(setup_client):
client, _ = setup_client
# Login ambil token
res_login = client.post('/api/auth/login', json={"npm": "202310715297", "password": "Password123", "device_id": "device_sah_01"})
jwt_token = json.loads(res_login.data)['data']['token']
# Koordinat di-offset sedikit dari KAMPUS_LATITUDE agar lolos [FIX 8] (Anomali Koordinat Identik)
# tetapi tetap masuk dalam radius 500m
lat_valid = flask_app.KAMPUS_LATITUDE + 0.0001
lon_valid = flask_app.KAMPUS_LONGITUDE + 0.0001
res_loc = client.post('/api/absensi/request-location-token',
json={"latitude": lat_valid, "longitude": lon_valid},
headers={'Authorization': f'Bearer {jwt_token}'}
)
data_loc = json.loads(res_loc.data)
assert res_loc.status_code == 200
assert "location_token" in data_loc
assert data_loc["expires_in_seconds"] == 120
@patch('app.requests.post') # Mock eksekusi jaringan eksternal (webhook) agar tidak hit API luar
@patch('app.validasi_foto') # Mock validasi foto agar tidak perlu kirim base64 5KB+ di script test
def test_it005_e2e_absensi_berhasil(mock_validasi_foto, mock_requests_post, setup_client):
client, id_jadwal = setup_client
mock_validasi_foto.return_value = (True, "OK")
# 1. Login
res_login = client.post('/api/auth/login', json={"npm": "202310715297", "password": "Password123", "device_id": "device_sah_01"})
jwt_token = json.loads(res_login.data)['data']['token']
# 2. Minta Location Token [FIX 7]
lat_valid = flask_app.KAMPUS_LATITUDE + 0.0001
lon_valid = flask_app.KAMPUS_LONGITUDE + 0.0001
res_loc = client.post('/api/absensi/request-location-token',
json={"latitude": lat_valid, "longitude": lon_valid},
headers={'Authorization': f'Bearer {jwt_token}'}
)
loc_token = json.loads(res_loc.data)['location_token']
# 3. Submit Absensi E2E
absensi_payload = {
"location_token": loc_token,
"id_jadwal": id_jadwal,
"foto_base64": "data:image/jpeg;base64,mocked_base64_string",
"status": "HADIR"
}
res_submit = client.post('/api/absensi/submit',
json=absensi_payload,
headers={'Authorization': f'Bearer {jwt_token}'}
)
assert res_submit.status_code == 201
assert json.loads(res_submit.data)['message'] == "Absensi berhasil disimpan"