Skip to content

Commit d74e11d

Browse files
w3(android): Robolectric + Turbine tests for ScanViewModel + QrPayload
ScanViewModelTest covers the seven cases from the W3 brief: 1. Idle → Scanning on permission grant 2. Scanning → ChallengeParsed on a valid QR (integrity tag verifies) 3. Scanning stays put on a malformed / typo'd QR 4. ChallengeParsed → AwaitingBiometric on approve (uses a blocking fake gate so the intermediate state is observable) 5. Happy path (FakeMobileProver) terminates in ProofReady carrying a za:proof:1: prefixed QR text 6. Biometric cancel → Error("biometric_cancelled") 7. Prover failure → Error("prover_failed") Plus three bonus cases: tenant label populated when the public metadata endpoint succeeds, retry from Error returns to Idle, and cancel from ChallengeParsed returns to Scanning. QrPayloadTest exercises the codec round-trip: parseChallenge happy path and three negative paths (wrong prefix, wrong segment count, integrity-tag mismatch) plus encodeProof shape + size-cap checks. Test infrastructure: * Robolectric runs the tests on the JVM (no emulator). @config(sdk = [30]) matches the app's minSdk so Build.MODEL doesn't fail under shadows. * Turbine wraps the StateFlow so awaitItem() asserts emission order cleanly without manual `value` reads. * Custom runVmTest helper passes the StandardTestDispatcher's scheduler to runTest so advanceUntilIdle() drains the viewModelScope launches (default runTest uses an independent scheduler that would silently miss the metadata-fetch coroutine). * mockito-kotlin provides the FragmentActivity stub the BiometricGate signature requires — the FakeBiometricGate never reads the activity, so a plain mock() is enough. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c3f9778 commit d74e11d

2 files changed

Lines changed: 495 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
package dev.zeroauth.android.ui.scan
2+
3+
import androidx.fragment.app.FragmentActivity
4+
import app.cash.turbine.test
5+
import dev.zeroauth.android.net.PairingSession
6+
import dev.zeroauth.android.net.SessionResponse
7+
import dev.zeroauth.android.net.ZeroAuthApi
8+
import dev.zeroauth.android.prover.ProverException
9+
import dev.zeroauth.android.sec.BiometricResult
10+
import dev.zeroauth.android.util.ClientMeta
11+
import dev.zeroauth.android.util.FakeBiometricGate
12+
import dev.zeroauth.android.util.FakeKeystoreManager
13+
import dev.zeroauth.android.util.FakeMobileProver
14+
import javax.crypto.Cipher
15+
import kotlinx.coroutines.Dispatchers
16+
import kotlinx.coroutines.ExperimentalCoroutinesApi
17+
import kotlinx.coroutines.test.StandardTestDispatcher
18+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
19+
import kotlinx.coroutines.test.advanceUntilIdle
20+
import kotlinx.coroutines.test.resetMain
21+
import kotlinx.coroutines.test.runTest
22+
import kotlinx.coroutines.test.setMain
23+
import org.junit.After
24+
import org.junit.Assert.assertEquals
25+
import org.junit.Assert.assertNotNull
26+
import org.junit.Assert.assertTrue
27+
import org.junit.Before
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
import org.mockito.kotlin.mock
31+
import org.robolectric.RobolectricTestRunner
32+
import org.robolectric.annotation.Config
33+
34+
/**
35+
* Unit tests for [ScanViewModel].
36+
*
37+
* Robolectric powers the test because [ScanViewModel] uses
38+
* `android.os.Build.MODEL` and `androidx.lifecycle.viewModelScope`,
39+
* both of which need an Android-shadowed JVM environment.
40+
*
41+
* The six required cases from the W3 brief:
42+
* 1. Idle → Scanning on permission grant
43+
* 2. Scanning → ChallengeParsed on valid QR
44+
* 3. Scanning stays on invalid QR
45+
* 4. ChallengeParsed → AwaitingBiometric on Approve
46+
* 5. Happy path (fake prover) ends in ProofReady
47+
* 6. Biometric cancel ⇒ Error("biometric_cancelled")
48+
* 7. Prover failure ⇒ Error("prover_failed")
49+
*
50+
* All dependencies are fakes from `util/FakeProverAndSec.kt`. The
51+
* FragmentActivity is mocked because the BiometricGate signature
52+
* requires it — the FakeBiometricGate never reads the activity.
53+
*
54+
* Test dispatcher: [Dispatchers.Main] is swapped for a
55+
* [StandardTestDispatcher] in `setUp`, scrubbed in `tearDown`. Most
56+
* tests use [advanceUntilIdle] to drain the viewModelScope.
57+
*/
58+
@OptIn(ExperimentalCoroutinesApi::class)
59+
@RunWith(RobolectricTestRunner::class)
60+
@Config(sdk = [30]) // Match minSdk so any APIs that check Build.VERSION pass.
61+
class ScanViewModelTest {
62+
63+
private val testScheduler = kotlinx.coroutines.test.TestCoroutineScheduler()
64+
private val mainDispatcher = StandardTestDispatcher(testScheduler)
65+
66+
@Before
67+
fun setUp() {
68+
Dispatchers.setMain(mainDispatcher)
69+
}
70+
71+
@After
72+
fun tearDown() {
73+
Dispatchers.resetMain()
74+
}
75+
76+
/**
77+
* Run a test against the SAME scheduler the ViewModel's coroutines
78+
* use. Without this, `runTest`'s default scheduler is independent
79+
* of [mainDispatcher.scheduler] and `advanceUntilIdle()` inside the
80+
* block doesn't drain `viewModelScope` launches.
81+
*/
82+
private fun runVmTest(block: suspend kotlinx.coroutines.test.TestScope.() -> Unit) =
83+
runTest(testScheduler, testBody = block)
84+
85+
// ─── Helpers ─────────────────────────────────────────────────
86+
87+
/** A challenge QR whose integrity tag verifies. Recompute when
88+
* editing — see DesktopChallenge.verifyIntegrityTag. */
89+
private val validChallengeQr: String by lazy {
90+
val sessionId = "9f8e2a4b-1c0d-4e9a-bd33-2a44f0e7e9d1"
91+
val nonceHex = "deadbeefcafebabe1234567890abcdef0123456789abcdef0123456789abcd"
92+
val tenantDomain = "demo.zeroauth.dev"
93+
val canonical = "$sessionId|$nonceHex|$tenantDomain"
94+
val digest = java.security.MessageDigest.getInstance("SHA-256")
95+
.digest(canonical.toByteArray())
96+
val tag = digest.copyOfRange(0, 2).joinToString("") { "%02x".format(it) }
97+
"za:pair:1:$sessionId:$nonceHex:$tenantDomain:$tag"
98+
}
99+
100+
/** Mock activity — the FakeBiometricGate never reads it. */
101+
private val mockActivity: FragmentActivity = mock()
102+
103+
/** A no-op ZeroAuthApi that throws on any call — exercising the
104+
* "metadata fetch is optional and failure-tolerant" branch. */
105+
private val throwingApi: ZeroAuthApi = object : ZeroAuthApi {
106+
override suspend fun getSession(bearer: String, id: String): SessionResponse {
107+
throw RuntimeException("simulated network failure")
108+
}
109+
}
110+
111+
/** A successful ZeroAuthApi that returns a labelled session. */
112+
private fun labelledApi(label: String): ZeroAuthApi = object : ZeroAuthApi {
113+
override suspend fun getSession(bearer: String, id: String): SessionResponse =
114+
SessionResponse(
115+
session = PairingSession(
116+
id = id,
117+
nonce = "00".repeat(31),
118+
state = "issued",
119+
expiresAt = "2026-05-25T14:35:00.000Z",
120+
initiatorLabel = label,
121+
tenantName = "ZeroAuth Demo Tenant",
122+
),
123+
)
124+
}
125+
126+
private fun newViewModel(
127+
keystore: FakeKeystoreManager = FakeKeystoreManager(),
128+
biometric: FakeBiometricGate = FakeBiometricGate(
129+
nextResult = BiometricResult.Success(Cipher.getInstance("AES/GCM/NoPadding")),
130+
),
131+
prover: FakeMobileProver = FakeMobileProver(delayMs = 0L),
132+
api: ZeroAuthApi = throwingApi,
133+
): ScanViewModel = ScanViewModel(
134+
keystoreManager = keystore,
135+
biometricGate = biometric,
136+
mobileProver = prover,
137+
api = api,
138+
ioDispatcher = UnconfinedTestDispatcher(mainDispatcher.scheduler),
139+
clientMetaFactory = {
140+
ClientMeta(
141+
appVersion = "test",
142+
model = "RobolectricEmu",
143+
proofMs = 0L,
144+
)
145+
},
146+
)
147+
148+
// ─── 1. Idle → Scanning on permission grant ──────────────────
149+
150+
@Test
151+
fun `permission grant transitions Idle to Scanning`() = runVmTest {
152+
val vm = newViewModel()
153+
vm.state.test {
154+
assertEquals(ScanState.Idle, awaitItem())
155+
vm.onPermissionGranted()
156+
assertEquals(ScanState.Scanning, awaitItem())
157+
cancelAndIgnoreRemainingEvents()
158+
}
159+
}
160+
161+
// ─── 2. Scanning → ChallengeParsed on valid QR ───────────────
162+
163+
@Test
164+
fun `valid challenge QR transitions Scanning to ChallengeParsed`() = runVmTest {
165+
val vm = newViewModel()
166+
vm.state.test {
167+
assertEquals(ScanState.Idle, awaitItem())
168+
vm.onPermissionGranted()
169+
assertEquals(ScanState.Scanning, awaitItem())
170+
171+
vm.onQrDetected(validChallengeQr)
172+
val parsed = awaitItem()
173+
assertTrue(parsed is ScanState.ChallengeParsed)
174+
assertEquals(
175+
"9f8e2a4b-1c0d-4e9a-bd33-2a44f0e7e9d1",
176+
(parsed as ScanState.ChallengeParsed).challenge.sessionId,
177+
)
178+
// tenantLabel starts null; the metadata fetch is async.
179+
assertEquals(null, parsed.tenantLabel)
180+
cancelAndIgnoreRemainingEvents()
181+
}
182+
}
183+
184+
// ─── Bonus: tenantLabel updates when /public returns one ─────
185+
186+
@Test
187+
fun `tenant label is populated when the public metadata endpoint succeeds`() = runVmTest {
188+
val vm = newViewModel(api = labelledApi("Chrome on MacBook Pro"))
189+
vm.state.test {
190+
assertEquals(ScanState.Idle, awaitItem())
191+
vm.onPermissionGranted()
192+
assertEquals(ScanState.Scanning, awaitItem())
193+
vm.onQrDetected(validChallengeQr)
194+
// First emission: ChallengeParsed with null label.
195+
val first = awaitItem() as ScanState.ChallengeParsed
196+
assertEquals(null, first.tenantLabel)
197+
198+
advanceUntilIdle() // drain the metadata fetch coroutine
199+
200+
val updated = awaitItem() as ScanState.ChallengeParsed
201+
assertEquals("Chrome on MacBook Pro", updated.tenantLabel)
202+
cancelAndIgnoreRemainingEvents()
203+
}
204+
}
205+
206+
// ─── 3. Scanning stays on invalid QR ─────────────────────────
207+
208+
@Test
209+
fun `invalid QR keeps the state machine in Scanning`() = runVmTest {
210+
val vm = newViewModel()
211+
vm.onPermissionGranted()
212+
mainDispatcher.scheduler.advanceUntilIdle()
213+
assertEquals(ScanState.Scanning, vm.state.value)
214+
215+
// Wrong prefix
216+
vm.onQrDetected("https://zeroauth.dev")
217+
assertEquals(ScanState.Scanning, vm.state.value)
218+
219+
// Right prefix, wrong segment count
220+
vm.onQrDetected("za:pair:1:only-one-segment")
221+
assertEquals(ScanState.Scanning, vm.state.value)
222+
223+
// Right prefix + count, wrong integrity tag
224+
vm.onQrDetected("za:pair:1:abc:def:zeroauth.dev:0000")
225+
assertEquals(ScanState.Scanning, vm.state.value)
226+
}
227+
228+
// ─── 4. ChallengeParsed → AwaitingBiometric on Approve ───────
229+
230+
@Test
231+
fun `approve from ChallengeParsed transitions through AwaitingBiometric`() = runVmTest {
232+
// Use a biometric gate that BLOCKS so we can observe the
233+
// AwaitingBiometric state before it resolves.
234+
val biometric = FakeBiometricGate(
235+
nextResult = BiometricResult.Success(Cipher.getInstance("AES/GCM/NoPadding")),
236+
delayMs = 1_000,
237+
)
238+
val vm = newViewModel(biometric = biometric)
239+
240+
vm.onPermissionGranted()
241+
mainDispatcher.scheduler.advanceUntilIdle()
242+
vm.onQrDetected(validChallengeQr)
243+
mainDispatcher.scheduler.runCurrent()
244+
assertTrue(vm.state.value is ScanState.ChallengeParsed)
245+
246+
vm.onBiometricApproved(mockActivity, FakeKeystoreManager.DEMO_EMAIL)
247+
mainDispatcher.scheduler.runCurrent()
248+
assertEquals(ScanState.AwaitingBiometric, vm.state.value)
249+
}
250+
251+
// ─── 5. End-to-end happy path → ProofReady ───────────────────
252+
253+
@Test
254+
fun `happy path ends in ProofReady with a za_proof_1 QR text`() = runVmTest {
255+
val keystore = FakeKeystoreManager()
256+
val prover = FakeMobileProver(delayMs = 0L)
257+
val vm = newViewModel(keystore = keystore, prover = prover)
258+
259+
vm.onPermissionGranted()
260+
mainDispatcher.scheduler.advanceUntilIdle()
261+
vm.onQrDetected(validChallengeQr)
262+
mainDispatcher.scheduler.runCurrent()
263+
vm.onBiometricApproved(mockActivity, FakeKeystoreManager.DEMO_EMAIL)
264+
mainDispatcher.scheduler.advanceUntilIdle()
265+
266+
val terminal = vm.state.value
267+
assertTrue(
268+
"Expected ProofReady but got ${terminal::class.simpleName}",
269+
terminal is ScanState.ProofReady,
270+
)
271+
terminal as ScanState.ProofReady
272+
assertTrue(
273+
"QR text should carry the proof prefix; was ${terminal.qrText.take(20)}",
274+
terminal.qrText.startsWith("za:proof:1:"),
275+
)
276+
277+
// The fake credential should have been closed after generate.
278+
val issued = keystore.lastIssued
279+
assertNotNull("FakeKeystoreManager should have issued a credential", issued)
280+
}
281+
282+
// ─── 6. Biometric cancel ⇒ Error("biometric_cancelled") ──────
283+
284+
@Test
285+
fun `biometric cancel transitions to Error with stable code`() = runVmTest {
286+
val biometric = FakeBiometricGate(nextResult = BiometricResult.Cancelled)
287+
val vm = newViewModel(biometric = biometric)
288+
289+
vm.onPermissionGranted()
290+
mainDispatcher.scheduler.advanceUntilIdle()
291+
vm.onQrDetected(validChallengeQr)
292+
mainDispatcher.scheduler.runCurrent()
293+
vm.onBiometricApproved(mockActivity, FakeKeystoreManager.DEMO_EMAIL)
294+
mainDispatcher.scheduler.advanceUntilIdle()
295+
296+
val terminal = vm.state.value
297+
assertTrue(terminal is ScanState.Error)
298+
terminal as ScanState.Error
299+
assertEquals("biometric_cancelled", terminal.code)
300+
}
301+
302+
// ─── 7. Prover failure ⇒ Error("prover_failed") ──────────────
303+
304+
@Test
305+
fun `prover failure transitions to Error with prover_failed code`() = runVmTest {
306+
val prover = FakeMobileProver(
307+
delayMs = 0L,
308+
failWith = ProverException(
309+
code = ProverException.PROVER_FAILED,
310+
message = "snarkjs threw",
311+
),
312+
)
313+
val vm = newViewModel(prover = prover)
314+
315+
vm.onPermissionGranted()
316+
mainDispatcher.scheduler.advanceUntilIdle()
317+
vm.onQrDetected(validChallengeQr)
318+
mainDispatcher.scheduler.runCurrent()
319+
vm.onBiometricApproved(mockActivity, FakeKeystoreManager.DEMO_EMAIL)
320+
mainDispatcher.scheduler.advanceUntilIdle()
321+
322+
val terminal = vm.state.value
323+
assertTrue(terminal is ScanState.Error)
324+
terminal as ScanState.Error
325+
assertEquals("prover_failed", terminal.code)
326+
}
327+
328+
// ─── Bonus: retry from Error → Idle ──────────────────────────
329+
330+
@Test
331+
fun `retry from Error returns to Idle`() = runVmTest {
332+
val biometric = FakeBiometricGate(nextResult = BiometricResult.Cancelled)
333+
val vm = newViewModel(biometric = biometric)
334+
335+
vm.onPermissionGranted()
336+
mainDispatcher.scheduler.advanceUntilIdle()
337+
vm.onQrDetected(validChallengeQr)
338+
mainDispatcher.scheduler.runCurrent()
339+
vm.onBiometricApproved(mockActivity, FakeKeystoreManager.DEMO_EMAIL)
340+
mainDispatcher.scheduler.advanceUntilIdle()
341+
assertTrue(vm.state.value is ScanState.Error)
342+
343+
vm.retry()
344+
assertEquals(ScanState.Idle, vm.state.value)
345+
}
346+
347+
// ─── Bonus: Cancel from ChallengeParsed → Scanning ───────────
348+
349+
@Test
350+
fun `cancel from ChallengeParsed returns to Scanning`() = runVmTest {
351+
val vm = newViewModel()
352+
vm.onPermissionGranted()
353+
mainDispatcher.scheduler.advanceUntilIdle()
354+
vm.onQrDetected(validChallengeQr)
355+
mainDispatcher.scheduler.runCurrent()
356+
assertTrue(vm.state.value is ScanState.ChallengeParsed)
357+
358+
vm.onChallengeCancelled()
359+
assertEquals(ScanState.Scanning, vm.state.value)
360+
}
361+
}

0 commit comments

Comments
 (0)