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 {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@ -68,6 +72,13 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling)
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)
implementation("androidx.camera:camera-core: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 {
// 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://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)
// const val KAMPUS_LATITUDE = -6.223325
// const val KAMPUS_LONGITUDE = 107.009406
const val KAMPUS_LATITUDE = -6.223325
const val KAMPUS_LONGITUDE = 107.009406
// Koordinat Device Saat ini (Untuk Testing)
const val KAMPUS_LATITUDE = -6.2396008
const val KAMPUS_LONGITUDE = 107.0893571
// const val KAMPUS_LATITUDE = -6.2394664
// const val KAMPUS_LONGITUDE = 107.0898995
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
import jwt
import bcrypt
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from functools import wraps
import base64
import math
@ -46,14 +46,15 @@ DB_CONFIG = {
SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this'
# Lokasi Kampus
# KAMPUS_LATITUDE = -6.223325
# KAMPUS_LONGITUDE = 107.009406
# RADIUS_METER = 500.0
KAMPUS_LATITUDE = -6.223325
KAMPUS_LONGITUDE = 107.009406
KAMPUS_LONGITUDE = 107.009406
RADIUS_METER = 500.0
# Testing
KAMPUS_LATITUDE = -6.2396008
KAMPUS_LONGITUDE = 107.0893571
RADIUS_METER = 500.0
# KAMPUS_LATITUDE = -6.2394664
# KAMPUS_LONGITUDE = 107.0898995
# RADIUS_METER = 500.0
# ==================== [FIX 7] LOCATION TOKEN STORE ====================
# 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,
'npm': npm,
'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')

View File

@ -6,4 +6,5 @@ PyJWT==2.8.0
bcrypt==4.1.2
python-dotenv==1.0.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"