feat(android): access-token support — same frozen contract as iOS

Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:27 +02:00
parent a5fa843f00
commit 9114630c3a
42 changed files with 2419 additions and 51 deletions

View File

@@ -0,0 +1,118 @@
package wang.yaojia.webterm.hostregistry
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/**
* B5 · the per-host access-token store contract (ios-completion §1.1), exercised through the pure
* in-memory double (the Tink/AndroidKeyStore-backed implementation is instrumented → device QA):
* - a token is keyed by the endpoint's CANONICAL origin, so the same server dialed with a trailing
* slash / different case / a path resolves to the SAME token, and a different host never does;
* - a malformed token is REJECTED at the boundary and never stored;
* - `remove` is idempotent, and the store doubles as the `AccessTokenSource` the transports consume.
*/
class InMemoryAccessTokenStoreTest {
private companion object {
const val TOKEN = "0123456789abcdefTOKEN"
const val OTHER_TOKEN = "fedcba9876543210OTHER"
}
private fun endpoint(url: String): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
@Test
fun `a stored token is returned for the same host`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
assertTrue(store.put(host, TOKEN))
assertEquals(TOKEN, store.tokenFor(host))
}
@Test
fun `an unknown host has no token`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.6:3000")))
}
@Test
fun `the key is the canonical origin so the same server matches however it was dialed`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
assertEquals(TOKEN, store.tokenFor(endpoint("HTTP://10.0.0.5:3000")))
}
@Test
fun `a different port is a different host`() = runTest {
val store = InMemoryAccessTokenStore()
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
assertNull(store.tokenFor(endpoint("http://10.0.0.5:3001")))
}
@Test
fun `putting again replaces the token for that host only`() = runTest {
val store = InMemoryAccessTokenStore()
val a = endpoint("http://10.0.0.5:3000")
val b = endpoint("http://10.0.0.6:3000")
store.put(a, TOKEN)
store.put(b, OTHER_TOKEN)
store.put(a, OTHER_TOKEN)
assertEquals(OTHER_TOKEN, store.tokenFor(a))
assertEquals(OTHER_TOKEN, store.tokenFor(b))
}
@Test
fun `a malformed token is rejected and never stored`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
assertFalse(store.put(host, "short"), "below the server's 16-char minimum")
assertFalse(store.put(host, "has spaces in it here"), "outside the server charset")
assertNull(store.tokenFor(host), "a rejected token must not reach storage")
}
@Test
fun `a whitespace-padded token is trimmed once on the way in`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
store.put(host, " $TOKEN\n")
assertEquals(TOKEN, store.tokenFor(host))
}
@Test
fun `remove drops the token and is idempotent`() = runTest {
val store = InMemoryAccessTokenStore()
val host = endpoint("http://10.0.0.5:3000")
store.put(host, TOKEN)
store.remove(host)
store.remove(host)
assertNull(store.tokenFor(host))
}
@Test
fun `the store is usable directly as the transports' AccessTokenSource`() = runTest {
val store: wang.yaojia.webterm.wire.AccessTokenSource = InMemoryAccessTokenStore(
initial = mapOf(endpoint("http://10.0.0.5:3000").originHeader to TOKEN),
)
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000")))
}
}