fix(android): make the terminal actually usable on a device

The client built to an APK and 617 JVM tests passed, but nothing had ever
run on hardware, and three defects made it unusable the moment it did. All
three were invisible to the JVM suite because none of those tests
instantiate an Android View.

1. Every key press crashed. `RemoteTerminalView` bound the forked emulator
   but never installed a `TerminalViewClient`, and the stock Termux view
   dereferences `mClient` with no null guard on the first line of the paths
   a user actually hits (`onKeyDown` offset 82, `onCreateInputConnection`
   offset 0, `onKeyUp`, `onKeyPreIme`). Installing any client is not enough:
   returning false continues into `mTermSession.write()` at offset 150, and
   `mTermSession` is null forever because `TerminalSession` is final and
   forking a process is what this app must not do. So the client consumes
   the key itself and `KeyRouting` has no defer-to-stock branch at all —
   the crash is unrepresentable, not merely avoided. Termux's `KeyHandler`
   stays the authority for the key tables, so DECCKM still emits `ESC O A`.

2. The PTY never learned the real size. Nothing called
   `RemoteTerminalSession.updateSize`, and the stock fallback is dead
   (`TerminalView.updateSize` early-returns without a session), so every
   full-screen TUI rendered into 80x24. `TerminalResizeDriver` now drives
   it from real cell metrics — read through the public `getFontWidth()` /
   `getFontLineSpacing()` accessors rather than reflection, which would
   break silently under R8, or a re-measured Paint, which would drift from
   what the renderer actually draws (plan risk R5).

3. Bare-LAN connections were impossible. `network_security_config.xml` was
   a self-labelled STUB denying all cleartext while `HostEndpoint` accepts
   http and derives ws://. The format has no CIDR syntax, so the allowlist
   its own comment promised cannot be written; cleartext is permitted in
   base-config, the tunnel domain keeps a TLS block, trust anchors are
   pinned to system-only, and the guard moves to the §5.4 pairing tiers.

Adversarial review then found three more, and one it got wrong:

  - Swipe-scrolling inside an alternate-screen TUI still crashed. The touch
    path bypasses `TerminalViewClient` entirely: `doScroll` turns a scroll
    into `handleKeyCode` whenever the alternate buffer is active, and that
    dereferences `mTermSession`. Claude Code is an alternate-screen TUI, so
    this was a crash during normal use on the app's main screen.
    `TerminalScrollGesture` claims the vertical drag first and reproduces
    all three `doScroll` branches, closing the fling runnable and
    `onGenericMotionEvent` with it.
  - `autofill()` dereferences `mTermSession` unguarded while the view
    advertises itself autofillable, so any password manager crashed it.
    The subtree is excluded from the autofill structure.
  - The review asked for stock text selection to be restored. It cannot be:
    the ActionMode's own Copy and Paste handlers dereference the absent
    session, so a reachable selection UI has an NPE on its Copy button.
    Long press stays consumed and copy-out is recorded as a feature gap.

Also: the WEBTERM_TOKEN cookie is carried on REST and the WS upgrade and
sealed with Tink AEAD under an AndroidKeyStore key (fail-closed — a seal
failure persists nothing rather than falling back to plaintext), and
`allowBackup=false` keeps a 30-day shell credential off Google Drive.

Verified: ./gradlew test :app:assembleDebug -> 757 JVM tests, 0 failures.
Nothing here is device-verified yet; that is the next step.
This commit is contained in:
Yaojia Wang
2026-07-30 08:59:01 +02:00
parent c3613c2fae
commit 3e5cfdc1cf
48 changed files with 5719 additions and 85 deletions

View File

@@ -0,0 +1,98 @@
package wang.yaojia.webterm.hostregistry
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* At-rest encoding for the auth-cookie list. Cookie records are UNTRUSTED at rest (same posture as
* `HostCodec`): a corrupt blob starts clean, a structurally invalid record is dropped, and an
* already-expired record is dropped on read so a stale shell credential is never resurrected after
* a long background.
*/
class AuthCookieCodecTest {
private fun record(
hostKey: String = "http://192.168.1.5:3000",
name: String = "webterm_auth",
expiresAtEpochMillis: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = TOKEN,
domain = "192.168.1.5",
path = "/",
expiresAtEpochMillis = expiresAtEpochMillis,
secure = false,
httpOnly = true,
hostOnly = true,
)
@Test
fun roundTripsEveryFieldOfALiveRecord() {
// Arrange
val original = listOf(record(), record(hostKey = "https://tunnel.example:443", name = "other"))
// Act
val decoded = AuthCookieCodec.decode(AuthCookieCodec.encode(original), NOW)
// Assert
assertEquals(original, decoded)
}
@Test
fun decodingACorruptBlobStartsClean() {
assertEquals(emptyList<AuthCookieRecord>(), AuthCookieCodec.decode("{not json", NOW))
}
@Test
fun decodingAnAbsentOrBlankBlobIsEmpty() {
assertEquals(emptyList<AuthCookieRecord>(), AuthCookieCodec.decode(null, NOW))
assertEquals(emptyList<AuthCookieRecord>(), AuthCookieCodec.decode(" ", NOW))
}
@Test
fun dropsARecordWithABlankHostKeyOrName() {
// Arrange: a record with no host key could otherwise be replayed to ANY host.
val raw = AuthCookieCodec.encode(listOf(record(hostKey = ""), record(name = ""), record()))
// Act
val decoded = AuthCookieCodec.decode(raw, NOW)
// Assert
assertEquals(1, decoded.size)
assertEquals("http://192.168.1.5:3000", decoded.single().hostKey)
}
@Test
fun dropsARecordThatExpiredWhileAtRest() {
// Arrange
val raw = AuthCookieCodec.encode(listOf(record(expiresAtEpochMillis = NOW), record(name = "live")))
// Act
val decoded = AuthCookieCodec.decode(raw, NOW)
// Assert
assertEquals(listOf("live"), decoded.map { it.name })
}
@Test
fun theEncodedBlobIsTheOnlyPlaceTheCredentialAppears() {
// Arrange
val records = listOf(record())
// Act
val rendered = records.toString() + records.single().toString() + AuthCookieCodec.toString()
// Assert: the blob must hold the value (it IS the persisted credential), but no toString may.
assertTrue(AuthCookieCodec.encode(records).contains(TOKEN))
assertFalse(rendered.contains(TOKEN), "credential leaked into a toString path: $rendered")
}
private companion object {
const val TOKEN = "s3cr3t-webterm-token-value"
const val NOW = 1_800_000_000_000L
const val FAR_FUTURE = 4_000_000_000_000L
}
}

View File

@@ -0,0 +1,172 @@
package wang.yaojia.webterm.hostregistry
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The pure immutable list transforms behind every [AuthCookieStore] — same discipline as the
* `HostStore` transforms: never mutate the receiver, upsert preserves position, an unknown id is
* an explicit no-op. Host isolation is a transform-level property here: `forHost` NEVER returns a
* record filed under a different host key (a shell credential — a cross-host leak is a security
* bug), and `removingHost` never touches another host's records.
*/
class AuthCookieStoreTransformsTest {
private val hostA = "http://192.168.1.5:3000"
private val hostB = "https://h7fd8.terminal.yaojia.wang:443"
private fun record(
hostKey: String,
name: String = "webterm_auth",
value: String = "token-$hostKey",
path: String = "/",
expiresAtEpochMillis: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = hostKey.substringAfter("://").substringBefore(":"),
path = path,
expiresAtEpochMillis = expiresAtEpochMillis,
)
@Test
fun upsertingAppendsAnUnknownRecordAndLeavesTheReceiverUntouched() {
// Arrange
val original = listOf(record(hostA))
// Act
val updated = original.upserting(record(hostB))
// Assert
assertEquals(1, original.size, "the receiver must never be mutated")
assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
assertNotSame(original, updated)
}
@Test
fun upsertingReplacesTheSameIdInPlacePreservingPosition() {
// Arrange
val original = listOf(record(hostA, value = "old"), record(hostB))
// Act
val updated = original.upserting(record(hostA, value = "new"))
// Assert
assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
assertEquals("new", updated.first().value)
assertEquals("old", original.first().value)
}
@Test
fun aDifferentCookieNameOnTheSameHostIsASeparateRecord() {
// Arrange
val original = listOf(record(hostA, name = "webterm_auth"))
// Act
val updated = original.upserting(record(hostA, name = "other"))
// Assert: identity is (hostKey, domain, path, name) — RFC 6265's key plus our host scope.
assertEquals(2, updated.size)
}
@Test
fun removingAnUnknownIdIsANoOp() {
// Arrange
val original = listOf(record(hostA))
// Act
val updated = original.removing(record(hostB).id)
// Assert
assertEquals(original, updated)
}
@Test
fun removingDropsOnlyTheMatchingRecord() {
// Arrange
val target = record(hostA)
val original = listOf(target, record(hostB))
// Act
val updated = original.removing(target.id)
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
assertEquals(2, original.size)
}
@Test
fun forHostReturnsOnlyThatHostsRecords() {
// Arrange
val original = listOf(record(hostA), record(hostB), record(hostA, name = "second"))
// Act
val forA = original.forHost(hostA)
// Assert
assertTrue(forA.all { it.hostKey == hostA }, "a host's cookies must never include another host's")
assertEquals(2, forA.size)
}
@Test
fun removingHostClearsOneHostAndLeavesTheOtherIntact() {
// Arrange
val original = listOf(record(hostA), record(hostB), record(hostA, name = "second"))
// Act
val updated = original.removingHost(hostA)
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
}
@Test
fun replacingHostSwapsOneHostsRecordsWholesale() {
// Arrange
val original = listOf(record(hostA, value = "stale"), record(hostB))
// Act
val updated = original.replacingHost(hostA, listOf(record(hostA, value = "fresh")))
// Assert
assertEquals(setOf(hostA, hostB), updated.map { it.hostKey }.toSet())
assertEquals("fresh", updated.single { it.hostKey == hostA }.value)
}
@Test
fun replacingHostRejectsRecordsFiledUnderADifferentHost() {
// Arrange: a mis-wired caller must not be able to smuggle host B's cookie under host A.
val original = listOf(record(hostB))
// Act
val updated = original.replacingHost(hostA, listOf(record(hostB, value = "smuggled")))
// Assert
assertFalse(
updated.any { it.hostKey == hostB && it.value == "smuggled" },
"replacingHost must drop records whose hostKey != the host being replaced",
)
}
@Test
fun droppingExpiredKeepsOnlyLiveRecords() {
// Arrange
val live = record(hostA, expiresAtEpochMillis = NOW + 1)
val expired = record(hostB, expiresAtEpochMillis = NOW)
// Act
val updated = listOf(live, expired).droppingExpired(NOW)
// Assert: expiry is inclusive — at the instant of expiry the cookie is already dead.
assertEquals(listOf(live), updated)
}
private companion object {
const val NOW = 1_800_000_000_000L
const val FAR_FUTURE = 4_000_000_000_000L
}
}

View File

@@ -0,0 +1,292 @@
package wang.yaojia.webterm.hostregistry
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.preferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
/**
* The AT-REST posture of [DataStoreAuthCookieStore] — what actually lands in the backing store.
*
* The persisted `webterm_auth` cookie is a **full-shell credential** with a 30-day server-side
* `Max-Age` (`src/http/auth.ts`), so plan §8's "encrypted, never in the clear" rule for the far
* weaker device-cert chain applies to it at least as strongly. These are the tests that would go red
* if the store ever wrote the value where a rooted device, an `adb` pull of a debug build, a cloud
* backup or a device-to-device transfer could read it.
*
* These run on the JVM (no Context) against a recording [DataStore] double, so the exact stored
* `Preferences` can be inspected byte-for-byte. The real Tink/AndroidKeyStore cipher is exercised
* instrumented in `:client-tls-android` (`TinkAuthCookieCipherTest`), and the DataStore file itself is
* checked on-device in `DataStoreAuthCookieStoreTest`.
*/
@DisplayName("DataStoreAuthCookieStore — at-rest encryption")
class DataStoreAuthCookieStoreCryptoTest {
private val hostA = "http://192.168.1.5:3000"
private val hostB = "https://tunnel.example:443"
private fun record(
hostKey: String = hostA,
name: String = "webterm_auth",
value: String = SECRET,
expiresAt: Long = FAR_FUTURE,
) = AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = "192.168.1.5",
path = "/",
expiresAtEpochMillis = expiresAt,
httpOnly = true,
)
// ── The properties that matter ────────────────────────────────────────────────────────────────
@Test
@DisplayName("the cookie value never lands in the backing store in any recoverable form")
fun theCookieValueIsNeverStoredRecoverably() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val store = newStore(dataStore)
// Act
store.upsert(record())
// Assert: nothing anywhere in the persisted Preferences reveals the credential — not raw, not
// Base64'd, not hex, not percent-encoded (a "cipher" that merely re-encodes must fail here).
val onDisk = dataStore.dump()
assertTrue(onDisk.isNotEmpty(), "the store wrote nothing at all — the round-trip test guards that")
recoverableFormsOf(SECRET).forEach { form ->
assertFalse(
onDisk.contains(form),
"a shell credential leaked into the backing store as <$form>: $onDisk",
)
}
}
@Test
@DisplayName("what is stored is the sealed blob, and it round-trips back through the cipher")
fun theSealedBlobRoundTrips() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
val a = record()
val b = record(hostKey = hostB, value = "other-host-token")
// Act
newStore(dataStore, cipher).upsert(a)
newStore(dataStore, cipher).upsert(b)
val reloaded = newStore(dataStore, cipher)
// Assert: a cold-start store over the same bytes sees both cookies, each on its own host.
assertEquals(listOf(a, b), reloaded.loadAll())
assertEquals(listOf(a), reloaded.loadForHost(hostA))
// ...and the ciphertext is an encryption OF the codec's JSON, not a per-field mangling.
assertEquals(AuthCookieCodec.encode(listOf(a, b)), cipher.open(dataStore.sealedBlob()!!))
}
@Test
@DisplayName("fail closed: when the cipher cannot seal, NOTHING is persisted (no plaintext fallback)")
fun aSealFailurePersistsNothing() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val failures = mutableListOf<Throwable>()
val store = newStore(
dataStore,
ReversibleTestCipher(failSeal = true),
onCipherFailure = { failures += it },
)
// Act
val updated = store.upsert(record())
// Assert: the in-memory answer is still correct (this process keeps working)...
assertEquals(listOf(record()), updated)
// ...but the credential never reached the disk, and the failure was reported, not swallowed.
assertEquals(emptyMap<Preferences.Key<*>, Any>(), dataStore.current().asMap())
assertEquals(1, failures.size, "a dropped persistence must be reported to the wiring")
}
@Test
@DisplayName("fail closed: a previously sealed blob is DELETED when sealing starts failing")
fun aSealFailureAlsoDropsTheStaleBlob() = runTest {
// Arrange: a good cipher persisted host A's cookie...
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
newStore(dataStore, cipher).upsert(record())
assertTrue(dataStore.sealedBlob() != null, "arrange failed: nothing was sealed")
// Act: ...then the keystore breaks (open still works, seal does not) and the set changes.
newStore(dataStore, ReversibleTestCipher(failSeal = true, failOpen = false, key = cipher.key))
.replaceHost(hostA, listOf(record(value = "rotated")))
// Assert: the superseded blob is gone rather than left behind as a resurrectable credential.
assertEquals(null, dataStore.sealedBlob())
}
@Test
@DisplayName("an unreadable blob (tamper / key lost with the device) reads as no cookies")
fun anUnreadableBlobYieldsNoCookies() = runTest {
// Arrange: sealed by a cipher whose key this store does not have.
val dataStore = RecordingPreferencesDataStore()
newStore(dataStore, ReversibleTestCipher(key = 0x5A)).upsert(record())
val failures = mutableListOf<Throwable>()
val store = newStore(
dataStore,
ReversibleTestCipher(failOpen = true),
onCipherFailure = { failures += it },
)
// Act
val loaded = store.loadAll()
// Assert
assertEquals(emptyList<AuthCookieRecord>(), loaded)
assertEquals(1, failures.size, "an unopenable credential blob must be reported")
}
@Test
@DisplayName("a legacy PLAINTEXT blob is never read, and is deleted by the next write")
fun aLegacyPlaintextBlobIsPurged() = runTest {
// Arrange: what a pre-encryption build would have left behind under the old key.
val legacyKey = stringPreferencesKey("authCookies")
val dataStore = RecordingPreferencesDataStore(
preferencesOf(legacyKey to AuthCookieCodec.encode(listOf(record()))),
)
val store = newStore(dataStore)
// Act + Assert: never read...
assertEquals(emptyList<AuthCookieRecord>(), store.loadAll())
// ...and the plaintext is gone after one write, so it cannot outlive the upgrade.
store.upsert(record(value = "fresh"))
assertEquals(null, dataStore.current()[legacyKey])
assertFalse(dataStore.dump().contains(SECRET), "the legacy plaintext survived: ${dataStore.dump()}")
}
@Test
@DisplayName("clearing the last cookie removes the blob instead of storing an empty one")
fun clearingRemovesTheBlob() = runTest {
// Arrange
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
val store = newStore(dataStore, cipher)
store.upsert(record())
// Act
store.removeHost(hostA)
// Assert
assertEquals(null, dataStore.sealedBlob())
}
@Test
@DisplayName("a record that expired at rest is dropped on read and garbage-collected on write")
fun expiryAtRestStillApplies() = runTest {
// Arrange: sealed while live, read back after the clock passed its expiry.
val dataStore = RecordingPreferencesDataStore()
val cipher = ReversibleTestCipher()
val expiresAt = 1_800_000_000_000L
newStore(dataStore, cipher, now = { expiresAt - 1 }).upsert(record(expiresAt = expiresAt))
// Act
val afterExpiry = newStore(dataStore, cipher, now = { expiresAt + 1 })
// Assert
assertEquals(emptyList<AuthCookieRecord>(), afterExpiry.loadAll())
assertEquals(
listOf(hostB),
afterExpiry.upsert(record(hostKey = hostB)).map { it.hostKey },
"a write must also garbage-collect the expired credential, not just hide it on read",
)
}
// ── Doubles ──────────────────────────────────────────────────────────────────────────────────
private fun newStore(
dataStore: DataStore<Preferences>,
cipher: AuthCookieCipher = ReversibleTestCipher(),
now: () -> Long = { NOW },
onCipherFailure: (Throwable) -> Unit = {},
) = DataStoreAuthCookieStore(
dataStore = dataStore,
cipher = cipher,
onCipherFailure = onCipherFailure,
now = now,
)
/**
* In-memory [DataStore] that keeps the exact [Preferences] the store wrote, so a test can inspect
* every key and value. Not a cipher test double — the real DataStore is byte-identical in what it
* is HANDED, which is the only thing these tests assert about.
*/
private class RecordingPreferencesDataStore(
initial: Preferences = emptyPreferences(),
) : DataStore<Preferences> {
private val state = MutableStateFlow(initial)
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
fun current(): Preferences = state.value
fun sealedBlob(): String? = state.value[stringPreferencesKey("authCookiesSealed")]
/** Every key AND value as one haystack — catches a leak under ANY key, not just the known one. */
fun dump(): String = state.value.asMap().entries.joinToString(";") { "${it.key.name}=${it.value}" }
}
/**
* A reversible stand-in for the real Tink AEAD: XOR under a byte key, then Base64. Strong enough
* for what these tests assert (the stored form is neither the plaintext nor an encoding of it, and
* a different key cannot open it) and weak on purpose — it lives in the TEST source set so it can
* never be wired into the app. [failSeal]/[failOpen] drive the fail-closed paths.
*/
private class ReversibleTestCipher(
private val failSeal: Boolean = false,
private val failOpen: Boolean = false,
val key: Byte = 0x2C,
) : AuthCookieCipher {
override fun seal(plaintext: String): String {
if (failSeal) throw IllegalStateException("test cipher: keystore unavailable")
return java.util.Base64.getEncoder().encodeToString(plaintext.toByteArray().xored())
}
override fun open(sealed: String): String {
if (failOpen) throw IllegalStateException("test cipher: blob failed authentication")
return String(java.util.Base64.getDecoder().decode(sealed).xored())
}
private fun ByteArray.xored(): ByteArray = ByteArray(size) { (this[it].toInt() xor key.toInt()).toByte() }
}
private companion object {
/** Distinctive so a leak is unambiguous, and long enough that a partial match is meaningful. */
const val SECRET = "SUPER_SECRET_SHELL_TOKEN_9f3a7c11"
const val NOW = 1_700_000_000_000L
const val FAR_FUTURE = 4_000_000_000_000L
/** Every encoding a naive "protection" might leave the credential recoverable through. */
fun recoverableFormsOf(secret: String): List<String> = listOf(
secret,
java.util.Base64.getEncoder().encodeToString(secret.toByteArray()),
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(secret.toByteArray()),
secret.toByteArray().joinToString("") { "%02x".format(it) },
java.net.URLEncoder.encode(secret, "UTF-8"),
)
}
}

View File

@@ -0,0 +1,124 @@
package wang.yaojia.webterm.hostregistry
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* The [AuthCookieStore] contract, exercised through the in-memory double that ships in `main`
* (it doubles for the DataStore store in the AW4 wiring tests, exactly like [InMemoryHostStore]).
*/
class InMemoryAuthCookieStoreTest {
private val hostA = "http://192.168.1.5:3000"
private val hostB = "https://tunnel.example:443"
private fun record(hostKey: String, name: String = "webterm_auth", value: String = "v") =
AuthCookieRecord(
hostKey = hostKey,
name = name,
value = value,
domain = "example",
path = "/",
expiresAtEpochMillis = FAR_FUTURE,
)
@Test
fun upsertReturnsTheNewListAndIsReadableBack() = runTest {
// Arrange
val store = InMemoryAuthCookieStore()
// Act
val afterFirst = store.upsert(record(hostA))
val afterSecond = store.upsert(record(hostB))
// Assert
assertEquals(1, afterFirst.size)
assertEquals(listOf(hostA, hostB), afterSecond.map { it.hostKey })
assertEquals(afterSecond, store.loadAll())
}
@Test
fun upsertReplacesTheSameRecordInPlace() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "old"), record(hostB)))
// Act
val updated = store.upsert(record(hostA, value = "new"))
// Assert
assertEquals(listOf(hostA, hostB), updated.map { it.hostKey })
assertEquals("new", updated.first().value)
}
@Test
fun loadForHostNeverLeaksAnotherHostsCookie() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB)))
// Act
val forA = store.loadForHost(hostA)
// Assert
assertEquals(1, forA.size)
assertTrue(forA.all { it.hostKey == hostA })
}
@Test
fun removeAnUnknownIdIsANoOp() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(listOf(record(hostA)))
// Act
val updated = store.remove(record(hostB).id)
// Assert
assertEquals(1, updated.size)
assertEquals(updated, store.loadAll())
}
@Test
fun removeHostClearsEveryCookieForThatHostOnly() = runTest {
// Arrange
val store = InMemoryAuthCookieStore(
listOf(record(hostA), record(hostA, name = "second"), record(hostB)),
)
// Act
val updated = store.removeHost(hostA)
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
assertEquals(emptyList<AuthCookieRecord>(), store.loadForHost(hostA))
}
@Test
fun replaceHostSwapsThatHostsCookiesWholesale() = runTest {
// Arrange: this is the write the OkHttp cookie jar drives — "here is host A's cookie set now".
val store = InMemoryAuthCookieStore(listOf(record(hostA, value = "stale"), record(hostB)))
// Act
val updated = store.replaceHost(hostA, listOf(record(hostA, value = "fresh")))
// Assert
assertEquals("fresh", updated.single { it.hostKey == hostA }.value)
assertEquals(1, updated.count { it.hostKey == hostB })
}
@Test
fun replaceHostWithAnEmptyListClearsThatHost() = runTest {
// Arrange: the server expired the cookie (Max-Age=0) → the jar reports an empty set.
val store = InMemoryAuthCookieStore(listOf(record(hostA), record(hostB)))
// Act
val updated = store.replaceHost(hostA, emptyList())
// Assert
assertEquals(listOf(hostB), updated.map { it.hostKey })
}
private companion object {
const val FAR_FUTURE = 4_000_000_000_000L
}
}