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:
@@ -45,6 +45,10 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
// Tink AEAD encrypts the per-host access-token blob at rest under an AndroidKeystore-wrapped
|
||||
// master key (B5 / ios-completion §1.1). A SEPARATE keyset from :client-tls-android's cert store —
|
||||
// different secrets, different lifecycles, and no module edge to the TLS half.
|
||||
implementation(libs.tink.android)
|
||||
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Instrumented (device) contract tests for [TinkAccessTokenStore] — Tink's AEAD + the
|
||||
* AndroidKeystore-wrapped master key need a real device Keystore, so these run on hardware (no
|
||||
* emulator in the build env → compiled here, executed in device QA, plan §7 / DEVICE_QA_CHECKLIST).
|
||||
*
|
||||
* They pin the same contract the JVM [InMemoryAccessTokenStoreTest] pins for the double, PLUS the two
|
||||
* things only the real store can prove: the token **survives a restart** (a fresh store instance
|
||||
* decrypts the blob) and the **at-rest bytes are ciphertext**, never the token in the clear.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TinkAccessTokenStoreTest {
|
||||
|
||||
private companion object {
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
}
|
||||
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
/** A store on a per-test keyset/pref-file so tests never share state. */
|
||||
private fun newStore(suffix: String): TinkAccessTokenStore = TinkAccessTokenStore(
|
||||
context = context,
|
||||
keysetName = "test_token_keyset_$suffix",
|
||||
prefFileName = "test_token_prefs_$suffix",
|
||||
masterKeyUri = "android-keystore://test_token_master_$suffix",
|
||||
)
|
||||
|
||||
private fun endpoint(url: String = "http://10.0.0.5:3000"): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url))
|
||||
|
||||
@Test
|
||||
fun put_then_tokenFor_returns_the_token() = runBlocking {
|
||||
val store = newStore(UUID.randomUUID().toString())
|
||||
|
||||
assertTrue(store.put(endpoint(), TOKEN))
|
||||
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_fresh_store_instance_decrypts_the_persisted_token() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
newStore(suffix).put(endpoint(), TOKEN)
|
||||
|
||||
// A new instance = a cold app start: no in-memory cache, must decrypt from disk.
|
||||
assertEquals(TOKEN, newStore(suffix).tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun the_token_is_never_stored_in_the_clear() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
newStore(suffix).put(endpoint(), TOKEN)
|
||||
|
||||
val raw = context
|
||||
.getSharedPreferences("test_token_prefs_$suffix", Context.MODE_PRIVATE)
|
||||
.all
|
||||
.values
|
||||
.joinToString(" ") { it.toString() }
|
||||
|
||||
assertFalse("the at-rest blob must be ciphertext", raw.contains(TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remove_drops_the_token_durably() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
val store = newStore(suffix)
|
||||
store.put(endpoint(), TOKEN)
|
||||
|
||||
store.remove(endpoint())
|
||||
|
||||
assertNull(store.tokenFor(endpoint()))
|
||||
assertNull("a removed token must not come back on a cold start", newStore(suffix).tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_malformed_token_is_rejected_without_touching_storage() = runBlocking {
|
||||
val suffix = UUID.randomUUID().toString()
|
||||
val store = newStore(suffix)
|
||||
|
||||
assertFalse(store.put(endpoint(), "short"))
|
||||
|
||||
assertNull(store.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tokens_are_keyed_per_host() = runBlocking {
|
||||
val store = newStore(UUID.randomUUID().toString())
|
||||
store.put(endpoint("http://10.0.0.5:3000"), TOKEN)
|
||||
|
||||
assertNull(store.tokenFor(endpoint("http://10.0.0.9:3000")))
|
||||
assertEquals(TOKEN, store.tokenFor(endpoint("http://10.0.0.5:3000/")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* Per-host storage for the optional shared access token (`WEBTERM_TOKEN`, ios-completion §1.1).
|
||||
*
|
||||
* It IS an [AccessTokenSource], so the very same instance that the pairing screen writes to is what
|
||||
* `:api-client` (every REST route) and `:transport-okhttp` (the WS upgrade) read from — one store, no
|
||||
* adapter, no cache to fall out of sync.
|
||||
*
|
||||
* ### Keying: the canonical origin, not the raw base URL
|
||||
* The key is [HostEndpoint.originHeader] — already single-point-derived, lower-cased, default-port
|
||||
* normalized. So `http://box:3000`, `HTTP://box:3000/` and `http://box:3000/some/path` are ONE host
|
||||
* (they are literally the same server, and the token is the server's), while a different port or host
|
||||
* is a different key. Storing under the raw `baseUrl` would silently lose the token when the same
|
||||
* server was re-paired from a URL with a trailing slash.
|
||||
*
|
||||
* ### Security contract (non-negotiable)
|
||||
* Implementations MUST keep the token device-only (Android-Keystore-wrapped, excluded from backup),
|
||||
* MUST validate through [AccessTokenRule] before persisting, and MUST NEVER log it.
|
||||
*/
|
||||
public interface AccessTokenStore : AccessTokenSource {
|
||||
/** The token stored for [endpoint]'s canonical origin, or null. Never logs. */
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String?
|
||||
|
||||
/**
|
||||
* Store [token] for [endpoint] (replacing any previous one). Returns **false** and stores nothing
|
||||
* when the token fails [AccessTokenRule] — a value the server could never accept must not be
|
||||
* persisted (nor round-tripped to disk) at all.
|
||||
*/
|
||||
public suspend fun put(endpoint: HostEndpoint, token: String): Boolean
|
||||
|
||||
/** Drop [endpoint]'s token. Idempotent — removing an unknown host is a no-op. */
|
||||
public suspend fun remove(endpoint: HostEndpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory [AccessTokenStore]. Lives in `main` (not `test`) on purpose — it doubles for the encrypted
|
||||
* store in this module's contract tests AND in the `:app` pairing-ViewModel tests (mirrors
|
||||
* [InMemoryHostStore]).
|
||||
*
|
||||
* A [Mutex] serializes the read-modify-write; the state is an immutable map replaced wholesale on every
|
||||
* change (never mutated in place).
|
||||
*/
|
||||
public class InMemoryAccessTokenStore(
|
||||
initial: Map<String, String> = emptyMap(),
|
||||
) : AccessTokenStore {
|
||||
private val mutex = Mutex()
|
||||
|
||||
// Defensive copy so a mutable map handed to the ctor cannot alias our state.
|
||||
private var tokensByOrigin: Map<String, String> = initial.toMap()
|
||||
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
|
||||
mutex.withLock { tokensByOrigin[endpoint.originHeader] }
|
||||
|
||||
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
|
||||
val normalized = AccessTokenRule.normalize(token) ?: return false
|
||||
mutex.withLock { tokensByOrigin = tokensByOrigin + (endpoint.originHeader to normalized) }
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun remove(endpoint: HostEndpoint) {
|
||||
mutex.withLock { tokensByOrigin = tokensByOrigin - endpoint.originHeader }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package wang.yaojia.webterm.hostregistry
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Base64
|
||||
import com.google.crypto.tink.Aead
|
||||
import com.google.crypto.tink.KeyTemplates
|
||||
import com.google.crypto.tink.RegistryConfiguration
|
||||
import com.google.crypto.tink.aead.AeadConfig
|
||||
import com.google.crypto.tink.integration.android.AndroidKeysetManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import wang.yaojia.webterm.wire.AccessTokenRule
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The production [AccessTokenStore]: per-host access tokens AEAD-encrypted with Tink under an
|
||||
* **AndroidKeystore-wrapped master key** (ios-completion §1.1 storage rule — the Android analogue of
|
||||
* iOS Keychain `…AfterFirstUnlockThisDeviceOnly` + no `kSecAttrSynchronizable`).
|
||||
*
|
||||
* What that buys, concretely:
|
||||
* - the master key lives in the Keystore (`android-keystore://…`) and is **non-exportable**, so the
|
||||
* on-disk ciphertext is useless on any other device — device-only, exactly like the cert store;
|
||||
* - the blob sits in an app-private `SharedPreferences` file (uninstall-wiped) and the app manifest
|
||||
* sets `android:allowBackup="false"`, so it is **never** in a cloud/adb backup;
|
||||
* - nothing here logs, and the token never enters a URL (see `AuthCookie`).
|
||||
*
|
||||
* Deliberately a SEPARATE keyset/pref-file/master-key from `:client-tls-android`'s `TinkCertStore`:
|
||||
* different secrets with different lifecycles must not share an AEAD keyset (and this module must not
|
||||
* gain a dependency on the TLS module). The ~20 lines of Tink setup are the price of that isolation.
|
||||
*
|
||||
* All disk + crypto work hops to [io] (never `Main`); the decrypted map is cached in memory after the
|
||||
* first read so the per-request [tokenFor] on the WS-dial / REST path is a cheap map lookup, and a
|
||||
* [Mutex] serializes the read-modify-write of a mutation. A blob that fails to decrypt/decode (tamper,
|
||||
* version skew, a wiped Keystore key after a device restore) degrades to "no tokens" — the user
|
||||
* re-enters the token — rather than throwing on every request.
|
||||
*/
|
||||
public class TinkAccessTokenStore(
|
||||
context: Context,
|
||||
private val io: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val keysetName: String = DEFAULT_KEYSET_NAME,
|
||||
private val prefFileName: String = DEFAULT_PREF_FILE,
|
||||
private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI,
|
||||
) : AccessTokenStore {
|
||||
private val appContext: Context = context.applicationContext
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** Decrypted `origin → token` map; null until the first read. Replaced wholesale, never mutated. */
|
||||
private var cache: Map<String, String>? = null
|
||||
|
||||
// Built on first use (registers AEAD, loads-or-creates the Keystore-wrapped keyset) — slow, so it
|
||||
// only ever happens inside a withContext(io) block.
|
||||
private val aead: Aead by lazy { buildAead() }
|
||||
|
||||
override suspend fun tokenFor(endpoint: HostEndpoint): String? =
|
||||
loadTokens()[endpoint.originHeader]
|
||||
|
||||
/**
|
||||
* Validate → encrypt → durably persist → publish to the cache, in that order: a token the server
|
||||
* could never accept returns false without touching disk, and the in-memory view is only updated
|
||||
* after the write is on disk (so a failed write can never leave a "stored" token that vanishes on
|
||||
* the next launch).
|
||||
*
|
||||
* @throws IOException when the durable write fails (infrastructure failure, distinct from `false`,
|
||||
* which means the token itself was malformed).
|
||||
*/
|
||||
override suspend fun put(endpoint: HostEndpoint, token: String): Boolean {
|
||||
val normalized = AccessTokenRule.normalize(token) ?: return false
|
||||
mutex.withLock {
|
||||
val updated = readThrough() + (endpoint.originHeader to normalized)
|
||||
persist(updated)
|
||||
cache = updated
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun remove(endpoint: HostEndpoint) {
|
||||
mutex.withLock {
|
||||
val current = readThrough()
|
||||
if (!current.containsKey(endpoint.originHeader)) return // idempotent, no needless write
|
||||
val updated = current - endpoint.originHeader
|
||||
persist(updated)
|
||||
cache = updated
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTokens(): Map<String, String> =
|
||||
cache ?: mutex.withLock {
|
||||
val loaded = readThrough()
|
||||
cache = loaded
|
||||
loaded
|
||||
}
|
||||
|
||||
/** Decrypt the stored blob (or the cache when warm). MUST be called with [mutex] held. */
|
||||
private suspend fun readThrough(): Map<String, String> =
|
||||
cache ?: withContext(io) { decodeBlob(prefs().getString(BLOB_KEY, null)) }
|
||||
|
||||
private suspend fun persist(tokens: Map<String, String>) {
|
||||
withContext(io) {
|
||||
val plaintext = JSON.encodeToString(TOKEN_MAP_SERIALIZER, tokens).encodeToByteArray()
|
||||
val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA)
|
||||
val committed = prefs().edit()
|
||||
.putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP))
|
||||
.commit() // synchronous + reports failure (as TinkCertStore); apply() would hide it
|
||||
if (!committed) throw IOException("failed to durably persist the access-token blob")
|
||||
}
|
||||
}
|
||||
|
||||
/** Any decrypt/decode failure ⇒ "no tokens" (never crash a request path on a corrupt blob). */
|
||||
private fun decodeBlob(encoded: String?): Map<String, String> {
|
||||
if (encoded == null) return emptyMap()
|
||||
return runCatching {
|
||||
val ciphertext = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
val plaintext = aead.decrypt(ciphertext, ASSOCIATED_DATA)
|
||||
JSON.decodeFromString(TOKEN_MAP_SERIALIZER, plaintext.decodeToString())
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
|
||||
private fun buildAead(): Aead {
|
||||
AeadConfig.register()
|
||||
val keysetHandle = AndroidKeysetManager.Builder()
|
||||
.withSharedPref(appContext, keysetName, prefFileName)
|
||||
.withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE))
|
||||
.withMasterKeyUri(masterKeyUri)
|
||||
.build()
|
||||
.keysetHandle
|
||||
return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java)
|
||||
}
|
||||
|
||||
private fun prefs(): SharedPreferences =
|
||||
appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE)
|
||||
|
||||
public companion object {
|
||||
private const val DEFAULT_KEYSET_NAME = "webterm_access_token_keyset"
|
||||
private const val DEFAULT_PREF_FILE = "webterm_access_token_prefs"
|
||||
private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_access_token_master_key"
|
||||
private const val AEAD_KEY_TEMPLATE = "AES256_GCM"
|
||||
private const val BLOB_KEY = "access_tokens_blob"
|
||||
|
||||
/** AEAD associated data — binds the ciphertext to this context so a lifted blob won't decrypt. */
|
||||
private val ASSOCIATED_DATA: ByteArray = "webterm.host-registry.access-tokens".toByteArray()
|
||||
|
||||
private val JSON = Json { ignoreUnknownKeys = true }
|
||||
private val TOKEN_MAP_SERIALIZER = MapSerializer(String.serializer(), String.serializer())
|
||||
}
|
||||
}
|
||||
@@ -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