Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ dependencies {
testImplementation("app.cash.turbine:turbine:1.2.1")
testImplementation("com.squareup.okhttp3:mockwebserver:5.4.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.11.1")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a Robolectric version that supports targetSdk 37

This project sets targetSdk = 37, but the newly added JVM test runner depends on Robolectric 4.11.1, whose supported SDK ceiling is much older; when ./gradlew test reaches SessionStoreTest, Robolectric validates the generated manifest target SDK before running the @Config(sdk = [34]) test and rejects apps whose targetSdkVersion is above its max SDK. That makes the release workflow's existing ./gradlew --no-daemon test assembleRelease step fail as soon as this test is included, so the dependency needs to be updated to a Robolectric release that supports the app target SDK or the test should avoid Robolectric.

Useful? React with πŸ‘Β / πŸ‘Ž.

testImplementation("org.mockito:mockito-core:5.8.0")
testImplementation("androidx.test:core-ktx:1.5.0")
testImplementation("androidx.test.ext:junit-ktx:1.1.5")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")

androidTestImplementation("androidx.compose.ui:ui-test-junit4")
Expand Down
171 changes: 171 additions & 0 deletions android/app/src/test/java/com/clhs/score/data/SessionStoreTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package com.clhs.score.data

import android.content.Context
import android.util.Base64
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Before
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import javax.crypto.Cipher
import javax.crypto.KeyGenerator

@RunWith(AndroidJUnit4::class)
@Config(sdk = [34], shadows = [ShadowMasterKey::class, ShadowEncryptedSharedPreferences::class, ShadowBiometricHelper::class])
class SessionStoreTest {

@Before
fun setup() {
System.setProperty("javax.net.ssl.trustStoreType", "JKS")
}

@Test
fun testSaveAndLoadSession() {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SessionStore(context)

val session = AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
store.saveSession(session)

val loaded = store.loadSession()
assertEquals(session, loaded)
}

@Test
fun testClearSession() {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SessionStore(context)

val session = AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
store.saveSession(session)

store.clearNormalSession()
assertNull(store.loadSession())
}

@Test
fun testReminderSession() {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SessionStore(context)

val session = AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
store.saveReminderSession(session, 1000)

val loaded = store.loadReminderSession(500)
assertEquals(session, loaded)

val expired = store.loadReminderSession(2000)
assertNull(expired)
assertNull(store.loadReminderSession(500))
}

@Test
fun testClearAll() {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SessionStore(context)

val session = AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
store.saveSession(session)
store.saveReminderSession(session, 1000)

store.clear()

assertNull(store.loadSession())
assertNull(store.loadReminderSession(500))
}

Comment on lines +71 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The clear() method in SessionStore clears all preferences, which includes standard, reminder, and biometric sessions. To ensure comprehensive test coverage, the testClearAll test should also save a biometric session and assert that it is successfully cleared after calling store.clear().

    @Test
    fun testClearAll() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val store = SessionStore(context)

        val session = AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
        store.saveSession(session)
        store.saveReminderSession(session, 1000)

        val keyGen = KeyGenerator.getInstance("AES")
        keyGen.init(256)
        val key = keyGen.generateKey()
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, key)
        store.saveBiometricSession(session, "1234", cipher)

        store.clear()

        assertNull(store.loadSession())
        assertNull(store.loadReminderSession(500))
        assertFalse(store.hasBiometricSession())
    }

@Test
fun testBiometricSession() {
val context = ApplicationProvider.getApplicationContext<Context>()
val store = SessionStore(context)

val session = AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
val keyGen = KeyGenerator.getInstance("AES")
keyGen.init(256)
val key = keyGen.generateKey()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)

store.saveBiometricSession(session, "1234", cipher)

assertTrue(store.hasBiometricSession())
assertNotNull(store.getBiometricIv())

val cipherDecrypt = Cipher.getInstance("AES/GCM/NoPadding")
cipherDecrypt.init(Cipher.DECRYPT_MODE, key, cipher.parameters)

val loaded = store.loadBiometricSession(cipherDecrypt)
assertEquals(session, loaded)

store.clearBiometricSession()
assertFalse(store.hasBiometricSession())
}
}

@Implements(androidx.security.crypto.MasterKey.Builder::class)
class ShadowMasterKey {
@Implementation
fun build(): androidx.security.crypto.MasterKey {
return org.mockito.Mockito.mock(androidx.security.crypto.MasterKey::class.java)
}
}

@Implements(androidx.security.crypto.EncryptedSharedPreferences::class)
class ShadowEncryptedSharedPreferences {
companion object {
@JvmStatic
@Implementation
fun create(
context: Context,
fileName: String,
masterKey: androidx.security.crypto.MasterKey,
prefKeyEncryptionScheme: androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme,
prefValueEncryptionScheme: androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme
): android.content.SharedPreferences {
return context.getSharedPreferences(fileName, Context.MODE_PRIVATE)
}
}
}

@Implements(BiometricHelper::class)
class ShadowBiometricHelper {
companion object {
@JvmStatic
@Implementation
fun encryptWithPin(session: AuthenticatedSession, pin: String, salt: ByteArray): com.clhs.score.data.BiometricHelper.EncryptedData {
return com.clhs.score.data.BiometricHelper.EncryptedData("cipher", Base64.encodeToString("iv".toByteArray(), Base64.NO_WRAP))
}

@JvmStatic
@Implementation
fun decryptWithPin(cipherText: String, ivBase64: String, pin: String, salt: ByteArray): AuthenticatedSession {
return AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
}

@JvmStatic
@Implementation
fun encryptPin(pin: String, cipher: Cipher): com.clhs.score.data.BiometricHelper.EncryptedData {
return com.clhs.score.data.BiometricHelper.EncryptedData("pinCipher", Base64.encodeToString("pinIv".toByteArray(), Base64.NO_WRAP))
}

@JvmStatic
@Implementation
fun decryptPin(cipherText: String, cipher: Cipher): String {
return "1234"
}

@JvmStatic
@Implementation
fun deleteSecretKey() {
// Do nothing
}
}
}
Comment on lines +138 to +171

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In Kotlin, object declarations compile to classes with instance methods (on the INSTANCE singleton) rather than static methods, unless they are explicitly annotated with @JvmStatic. Since BiometricHelper's methods are not annotated with @JvmStatic, Robolectric expects the shadow methods to be instance methods on ShadowBiometricHelper rather than static methods in a companion object. Defining them as static companion methods will cause Robolectric to fail to bind the shadow, which will result in the real BiometricHelper being executed (potentially causing AndroidKeyStore errors or slow PBKDF2 key derivation in tests).

Remove the companion object and @JvmStatic annotations to make them instance methods.

@Implements(BiometricHelper::class)
class ShadowBiometricHelper {
    @Implementation
    fun encryptWithPin(session: AuthenticatedSession, pin: String, salt: ByteArray): com.clhs.score.data.BiometricHelper.EncryptedData {
        return com.clhs.score.data.BiometricHelper.EncryptedData("cipher", Base64.encodeToString("iv".toByteArray(), Base64.NO_WRAP))
    }

    @Implementation
    fun decryptWithPin(cipherText: String, ivBase64: String, pin: String, salt: ByteArray): AuthenticatedSession {
        return AuthenticatedSession("123", "token", mapOf("cookie1" to "val1"))
    }

    @Implementation
    fun encryptPin(pin: String, cipher: Cipher): com.clhs.score.data.BiometricHelper.EncryptedData {
        return com.clhs.score.data.BiometricHelper.EncryptedData("pinCipher", Base64.encodeToString("pinIv".toByteArray(), Base64.NO_WRAP))
    }

    @Implementation
    fun decryptPin(cipherText: String, cipher: Cipher): String {
        return "1234"
    }

    @Implementation
    fun deleteSecretKey() {
        // Do nothing
    }
}

Loading