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:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 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")))
}
}