Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md
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