Lanjutan
This commit is contained in:
parent
79ebd33d14
commit
2366eb1119
@ -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")
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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')
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
137
backend/test_absensi_integration.py
Normal file
137
backend/test_absensi_integration.py
Normal 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"
|
||||||
Loading…
x
Reference in New Issue
Block a user