package social.grain import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import social.grain.data.api.DPoP import social.grain.data.auth.AuthManager import social.grain.data.auth.SecureStore import social.grain.data.auth.StoredAccount import social.grain.data.auth.TokenStorage /** * A stored session has to survive a restart. * * Regression test for a port bug that signed the user out on *every* launch: * iOS's one-shot "your grant is missing a scope, re-authenticate" migration was * carried over verbatim, but the server's token response omits `scope`, so the * check read every required scope as missing and wiped the credentials each * time `AuthManager` was constructed. */ @RunWith(AndroidJUnit4::class) class SessionPersistenceTest { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val store = SecureStore(context) private val tokenStorage = TokenStorage(store) private val fakeDid = "did:plc:sessiontestaccount" // The test writes into the same secure store the app uses, so whatever was // there is put back afterwards rather than left clobbered. private var savedActiveDid: String? = null private var savedAccounts: List = emptyList() @Before fun saveExistingState() { savedActiveDid = tokenStorage.activeDid savedAccounts = tokenStorage.accounts } @After fun restoreExistingState() { tokenStorage.removeAccount(fakeDid) tokenStorage.accounts = savedAccounts tokenStorage.activeDid = savedActiveDid } private fun seedSession() { tokenStorage.storeDpopKey(fakeDid, DPoP.createEphemeral().encodedKeyPair()) tokenStorage.storeTokens( did = fakeDid, accessToken = "access-token-value", refreshToken = "refresh-token-value", handle = "tester.grain.social", expiresAtMillis = System.currentTimeMillis() + 3_600_000, // The real server omits this, which is what made the ported // migration misfire. Seed it the same way. scope = null, ) tokenStorage.activeDid = fakeDid tokenStorage.upsertAccount(StoredAccount(fakeDid, "tester.grain.social", null)) } private fun newAuthManager() = AuthManager( tokenStorage = tokenStorage, http = OkHttpClient(), json = Json { ignoreUnknownKeys = true }, scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) @Test fun storedSessionSurvivesAFreshAuthManager() { seedSession() val auth = newAuthManager() assertTrue("Restored session should be authenticated", auth.state.value.isAuthenticated) assertEquals(fakeDid, auth.state.value.did) assertEquals("tester.grain.social", auth.state.value.handle) } @Test fun credentialsAreStillOnDiskAfterRestoring() { seedSession() // Construct repeatedly: the bug wiped credentials on each launch, so one // pass could look fine while the second came up empty. repeat(3) { newAuthManager() } assertNotNull("Access token was wiped", tokenStorage.accessToken(fakeDid)) assertNotNull("Refresh token was wiped", tokenStorage.refreshToken(fakeDid)) assertNotNull("DPoP key was wiped", tokenStorage.dpopKey(fakeDid)) assertTrue(tokenStorage.hasCredentials(fakeDid)) } @Test fun anAccountSwitchDuringRefreshDoesNotMixTokenAndKey() { // Pins the invariant, and does not currently fail without the fix: // when a terminal refresh signs an account out it also deletes that // account's token, so reading it with a stale DID returns null rather // than a mismatched pair. The mismatch needs a *successful* refresh // racing an account switch, which this app has no UI to trigger yet. // // Kept because the invariant is the thing that matters — a token and // the key that signs for it must come from the same DID — and an // account switcher would make the race reachable. val doomed = "did:plc:sessiontestdoomed" val survivor = "did:plc:sessiontestsurvivor" try { tokenStorage.storeDpopKey(survivor, DPoP.createEphemeral().encodedKeyPair()) tokenStorage.storeTokens( did = survivor, accessToken = "survivor-access-token", refreshToken = "survivor-refresh-token", handle = "survivor.grain.social", expiresAtMillis = System.currentTimeMillis() + 3_600_000, scope = null, ) tokenStorage.upsertAccount(StoredAccount(survivor, "survivor.grain.social", null)) tokenStorage.storeDpopKey(doomed, DPoP.createEphemeral().encodedKeyPair()) tokenStorage.storeTokens( did = doomed, accessToken = "doomed-access-token", refreshToken = "not-a-real-refresh-token", handle = "doomed.grain.social", expiresAtMillis = System.currentTimeMillis() - 1_000, scope = null, ) tokenStorage.upsertAccount(StoredAccount(doomed, "doomed.grain.social", null)) tokenStorage.activeDid = doomed val auth = newAuthManager() val context = runBlocking { auth.authContext() } // Whatever comes back must be internally consistent: never the // signed-out account's token. if (context != null) { assertNotEquals( "returned the signed-out account's token", "doomed-access-token", context.accessToken, ) val activeDid = auth.state.value.did assertNotNull("an account should have taken over", activeDid) assertEquals( "token must belong to the account that is now active", tokenStorage.accessToken(activeDid!!), context.accessToken, ) val expectedKey = DPoP.fromEncoded(tokenStorage.dpopKey(activeDid)!!) assertEquals( "key must belong to the same account as the token", expectedKey.thumbprint, context.dpop.thumbprint, ) } } finally { tokenStorage.removeAccount(doomed) tokenStorage.removeAccount(survivor) } } @Test fun aRefreshWithoutARotatedTokenKeepsTheExistingRefreshToken() { seedSession() // A refresh response that doesn't rotate omits refresh_token. Writing // that null through would delete the only credential able to renew the // session. tokenStorage.storeTokens( did = fakeDid, accessToken = "new-access-token", refreshToken = null, handle = null, expiresAtMillis = System.currentTimeMillis() + 3_600_000, scope = null, ) assertEquals("new-access-token", tokenStorage.accessToken(fakeDid)) assertEquals( "Refresh token should survive a non-rotating refresh", "refresh-token-value", tokenStorage.refreshToken(fakeDid), ) } }