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:
@@ -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")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user