From 2366eb1119b0e232419c284e42eec98ee1bb13ae Mon Sep 17 00:00:00 2001 From: Raihan Ariq <202310715297@mhs.ubharajaya.ac.id> Date: Sun, 10 May 2026 21:40:06 +0700 Subject: [PATCH] Lanjutan --- app/build.gradle.kts | 11 + .../ubharajaya/sistemakademik/AbsensiUtils.kt | 299 +++++++++ .../ubharajaya/sistemakademik/MainActivity.kt | 10 +- .../sistemakademik/AbsensiUtilsTest.kt | 572 ++++++++++++++++++ backend/app.py | 17 +- backend/requirements.txt | 3 +- backend/test_absensi_integration.py | 137 +++++ 7 files changed, 1035 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/id/ac/ubharajaya/sistemakademik/AbsensiUtils.kt create mode 100644 app/src/test/java/id/ac/ubharajaya/sistemakademik/AbsensiUtilsTest.kt create mode 100644 backend/test_absensi_integration.py diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41f031a..e09145e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/AbsensiUtils.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/AbsensiUtils.kt new file mode 100644 index 0000000..3acab30 --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/AbsensiUtils.kt @@ -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 { + // 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 { + 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 { + 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 { + if (password.length < 6) + return Pair(false, "Password minimal 6 karakter") + return Pair(true, "OK") + } + + fun validasiNama(nama: String): Pair { + 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 { + if (semester < 1 || semester > 14) + return Pair(false, "Semester tidak valid (1-14)") + return Pair(true, "OK") + } + + fun validasiDeviceId(deviceId: String): Pair { + 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 { + 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 { + 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 { + 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, + 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 { + 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 { + 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") + } +} \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt index 9e76ec1..db3823b 100644 --- a/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt +++ b/app/src/main/java/id/ac/ubharajaya/sistemakademik/MainActivity.kt @@ -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 diff --git a/app/src/test/java/id/ac/ubharajaya/sistemakademik/AbsensiUtilsTest.kt b/app/src/test/java/id/ac/ubharajaya/sistemakademik/AbsensiUtilsTest.kt new file mode 100644 index 0000000..6b0cdd6 --- /dev/null +++ b/app/src/test/java/id/ac/ubharajaya/sistemakademik/AbsensiUtilsTest.kt @@ -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 = 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) + } +} \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index 7caf4fc..e4f151a 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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') diff --git a/backend/requirements.txt b/backend/requirements.txt index 3495a51..d6214e9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 \ No newline at end of file +Flask-APScheduler==1.13.1 +pytest==8.2.0 \ No newline at end of file diff --git a/backend/test_absensi_integration.py b/backend/test_absensi_integration.py new file mode 100644 index 0000000..b26f67a --- /dev/null +++ b/backend/test_absensi_integration.py @@ -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" \ No newline at end of file