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:
@@ -15,6 +15,9 @@
|
||||
<application
|
||||
android:name=".WebTermApp"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.WebTerm"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
|
||||
@@ -79,11 +79,16 @@ public sealed interface BannerModel {
|
||||
|
||||
/**
|
||||
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
|
||||
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
|
||||
* spinner** (reconnecting would hit the same wall forever), and — where a fresh session would
|
||||
* actually help — a "新会话" affordance.
|
||||
*
|
||||
* [FailureReason.UNAUTHORIZED] deliberately offers NO new-session action (B5): the server 401'd the
|
||||
* upgrade, so a new session would 401 too; the fix is to re-enter the host's access token (or fix its
|
||||
* `ALLOWED_ORIGINS`), which the copy says.
|
||||
*/
|
||||
public data class Failed(val reason: FailureReason) : BannerModel {
|
||||
override val showsSpinner: Boolean get() = false
|
||||
override val offersNewSession: Boolean get() = true
|
||||
override val offersNewSession: Boolean get() = reason != FailureReason.UNAUTHORIZED
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,6 +189,8 @@ private fun bannerCopy(model: BannerModel): String = when (model) {
|
||||
is BannerModel.Failed -> when (model.reason) {
|
||||
FailureReason.REPLAY_TOO_LARGE ->
|
||||
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
|
||||
FailureReason.UNAUTHORIZED ->
|
||||
"服务器拒绝了连接(401)。请在“配对主机”里重新填写访问令牌;若已填写,请检查主机的 ALLOWED_ORIGINS。"
|
||||
}
|
||||
is BannerModel.Exited ->
|
||||
if (model.isSpawnFailure) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package wang.yaojia.webterm.di
|
||||
|
||||
import android.content.Context
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.TinkAccessTokenStore
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Access-token boundary (B5 / ios-completion §1.1): the ONE per-host access-token store, exposed both as
|
||||
* the mutable [AccessTokenStore] (the pairing screen writes it) and as the read-only
|
||||
* [AccessTokenSource] the transports consume.
|
||||
*
|
||||
* It is its own Hilt module — not part of [StorageModule] — because the token is SECRET material:
|
||||
* [StorageModule]'s DataStore holds only non-secret UI state, while this store is
|
||||
* Tink-AEAD-encrypted under an AndroidKeystore-wrapped master key (the same posture as [TlsModule]'s
|
||||
* cert store, with its own keyset so the two secrets never share a key).
|
||||
*
|
||||
* Both providers resolve to the SAME singleton, so a token stored during pairing is immediately visible
|
||||
* to the WS upgrade and to every REST route — no cache to invalidate. The store is constructor-I/O-free
|
||||
* (Tink/Keystore work is lazy and hops to `Dispatchers.IO` inside), so injecting it on `Main` is cheap.
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
public object AuthModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideAccessTokenStore(
|
||||
@ApplicationContext context: Context,
|
||||
): AccessTokenStore = TinkAccessTokenStore(context)
|
||||
|
||||
/** The transports' read seam — the SAME instance the pairing screen wrote to. */
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideAccessTokenSource(store: AccessTokenStore): AccessTokenSource = store
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import wang.yaojia.webterm.transport.OkHttpClientFactory
|
||||
import wang.yaojia.webterm.transport.OkHttpHttpTransport
|
||||
import wang.yaojia.webterm.transport.OkHttpTermTransport
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import javax.inject.Singleton
|
||||
@@ -65,10 +66,18 @@ public object NetworkModule {
|
||||
.connectionPool(connectionPool)
|
||||
.build()
|
||||
|
||||
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
|
||||
/**
|
||||
* WS transport over the shared client (it derives a streaming-tuned variant sharing the pool).
|
||||
*
|
||||
* [tokens] ([AuthModule]) is what makes the **WS upgrade** carry `Cookie: webterm_auth=<t>` — the
|
||||
* REST half gets its cookie from `:api-client`'s single stamping point instead (B5 / §1.1).
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
|
||||
public fun provideTermTransport(
|
||||
client: OkHttpClient,
|
||||
tokens: AccessTokenSource,
|
||||
): TermTransport = OkHttpTermTransport(client, tokens)
|
||||
|
||||
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
|
||||
@Provides
|
||||
|
||||
@@ -58,6 +58,10 @@ public fun PairingPane(
|
||||
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
|
||||
}
|
||||
},
|
||||
// B5 · the access-token half: validated with POST /auth, then kept in the Keystore-backed store
|
||||
// the transports read from.
|
||||
tokenStore = env.accessTokenStore,
|
||||
authProber = env.buildAccessTokenProber(),
|
||||
)
|
||||
}
|
||||
LaunchedEffect(viewModel) { viewModel.bind(this) }
|
||||
@@ -189,7 +193,8 @@ public fun DiffPane(
|
||||
}
|
||||
val viewModel = remember(resolved, path) {
|
||||
DiffViewModel(
|
||||
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport),
|
||||
// The hand-built diff route needs the access-token cookie too (B5 / §1.1).
|
||||
fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport, env.accessTokenStore),
|
||||
path = path,
|
||||
// Guarded git-write flows through :api-client's single Origin-stamping point (plan §Security).
|
||||
writer = ApiClientGitWriteGateway(env.apiClientFactory.create(resolved.endpoint)),
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -38,6 +39,9 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -50,8 +54,10 @@ import com.google.mlkit.vision.common.InputImage
|
||||
import wang.yaojia.webterm.designsystem.Spacing
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.viewmodels.EntryError
|
||||
import wang.yaojia.webterm.viewmodels.PairingCopy
|
||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||
import wang.yaojia.webterm.viewmodels.PairingViewModel
|
||||
import wang.yaojia.webterm.viewmodels.TokenError
|
||||
import wang.yaojia.webterm.viewmodels.WarningTier
|
||||
import wang.yaojia.webterm.viewmodels.needsExplicitAck
|
||||
|
||||
@@ -65,6 +71,12 @@ import wang.yaojia.webterm.viewmodels.needsExplicitAck
|
||||
* requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is
|
||||
* cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8).
|
||||
*
|
||||
* ### Access token (B5 / ios-completion §1.1)
|
||||
* The confirm step carries an OPTIONAL access-token field (masked, with a reveal toggle). The typed token
|
||||
* never enters the ViewModel's UI state — it is passed straight into `confirm()/retry()`, validated by
|
||||
* `POST /auth`, and (only when the server accepts it) stored in the Keystore-backed store. A wrong token /
|
||||
* rate-limit / "this host requires a token" comes back as an inert [TokenError] under the field.
|
||||
*
|
||||
* ### Permissions
|
||||
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
|
||||
* manual entry (the QR path is never mandatory).
|
||||
@@ -104,7 +116,7 @@ public fun PairingScreen(
|
||||
is PairingUiState.Confirming -> ConfirmStep(
|
||||
state = s,
|
||||
onName = viewModel::setName,
|
||||
onConfirm = viewModel::confirm,
|
||||
onConfirm = { ack, token -> viewModel.confirm(acknowledgedPublicRisk = ack, accessToken = token) },
|
||||
onCancel = viewModel::reset,
|
||||
)
|
||||
is PairingUiState.Probing -> ProbingStep(host = s.name)
|
||||
@@ -228,10 +240,16 @@ private fun CameraPreview(onScanned: (String) -> Unit) {
|
||||
private fun ConfirmStep(
|
||||
state: PairingUiState.Confirming,
|
||||
onName: (String) -> Unit,
|
||||
onConfirm: (Boolean) -> Unit,
|
||||
onConfirm: (Boolean, String) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
) {
|
||||
var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
|
||||
// The token lives ONLY here (and in the encrypted store once accepted) — never in the ViewModel's
|
||||
// UI state, so it cannot leak through a state snapshot / crash report (§1.1). Keyed on the endpoint
|
||||
// so a token typed for one host is dropped when the candidate changes, but survives a re-render
|
||||
// after a wrong-token error (the field keeps what the user typed).
|
||||
var token by remember(state.endpoint) { mutableStateOf("") }
|
||||
var tokenVisible by remember(state.endpoint) { mutableStateOf(false) }
|
||||
|
||||
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
|
||||
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
|
||||
@@ -249,6 +267,14 @@ private fun ConfirmStep(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
AccessTokenField(
|
||||
token = token,
|
||||
onToken = { token = it },
|
||||
visible = tokenVisible,
|
||||
onToggleVisible = { tokenVisible = !tokenVisible },
|
||||
error = state.tokenError,
|
||||
)
|
||||
|
||||
if (state.tier.needsExplicitAck) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
|
||||
@@ -259,13 +285,58 @@ private fun ConfirmStep(
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
|
||||
Button(
|
||||
onClick = { onConfirm(acknowledged) },
|
||||
onClick = { onConfirm(acknowledged, token) },
|
||||
enabled = !state.tier.needsExplicitAck || acknowledged,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("连接") }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional access-token field (B5 / ios-completion §1.1). Masked by default with an explicit
|
||||
* reveal toggle (a token is often typed from another screen and mistypes are the #1 failure), and
|
||||
* `KeyboardType.Password` + `autoCorrectEnabled = false` so the IME never learns or suggests it.
|
||||
* [error] renders the inert, app-authored copy for the typed [TokenError].
|
||||
*/
|
||||
@Composable
|
||||
private fun AccessTokenField(
|
||||
token: String,
|
||||
onToken: (String) -> Unit,
|
||||
visible: Boolean,
|
||||
onToggleVisible: () -> Unit,
|
||||
error: TokenError?,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = onToken,
|
||||
label = { Text("访问令牌(可选,WEBTERM_TOKEN)") },
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
autoCorrectEnabled = false,
|
||||
),
|
||||
trailingIcon = {
|
||||
TextButton(onClick = onToggleVisible) { Text(if (visible) "隐藏" else "显示") }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = PairingCopy.describeToken(error),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "主机未设置 WEBTERM_TOKEN 时留空即可。",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
|
||||
val (title, body) = tierCopy(tier)
|
||||
|
||||
@@ -18,6 +18,8 @@ import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.AuthCookie
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpMethod
|
||||
import wang.yaojia.webterm.wire.HttpRequest
|
||||
@@ -439,14 +441,23 @@ public class DiffUnavailable(public val status: Int) : Exception("diff unavailab
|
||||
/**
|
||||
* Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
|
||||
* Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
|
||||
*
|
||||
* This route is hand-built (it is not one of `:api-client`'s 12), so the access-token cookie must be
|
||||
* stamped HERE too: the server's auth gate covers EVERY route, and a missing cookie 401s (B5 /
|
||||
* ios-completion §1.1). The value comes from the single derivation point ([AuthCookie]); [tokens] is
|
||||
* the same store the transports read, and a host with no token adds no header at all.
|
||||
*/
|
||||
public class HttpDiffFetcher(
|
||||
private val endpoint: HostEndpoint,
|
||||
private val http: HttpTransport,
|
||||
private val tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
) : DiffFetcher {
|
||||
override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
|
||||
val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
|
||||
val headers = tokens.tokenFor(endpoint)
|
||||
?.let { mapOf(AuthCookie.HEADER_NAME to AuthCookie.headerValue(it)) }
|
||||
?: emptyMap()
|
||||
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url, headers = headers))
|
||||
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
|
||||
return decodeDiffResult(response.body)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import wang.yaojia.webterm.api.pairing.AuthProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.probeAccessToken
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostClassifier
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HostNetworkTier
|
||||
@@ -42,15 +47,31 @@ import java.util.UUID
|
||||
*
|
||||
* On probe success the validated host is persisted via [HostStore.upsert].
|
||||
*
|
||||
* ### RULE 5 — the access token is validated and stored BEFORE the probe (B5, ios-completion §1.1)
|
||||
* When the user typed a token, [confirm]/[retry] first validate it with `POST /auth` ([authProber]) and,
|
||||
* only if the server ACCEPTED it (204 **with** `Set-Cookie`), persist it to the Keystore-backed
|
||||
* [tokenStore]. That ordering is load-bearing: the two-step probe's own HTTP legs AND its WS upgrade
|
||||
* read the token back out of that same store, so they can only authenticate if the token is already in
|
||||
* it. A 204 **without** `Set-Cookie` means the host has no auth at all — nothing is stored and pairing
|
||||
* continues (never treated as "authenticated"). A wrong token / rate-limit / malformed token keeps the
|
||||
* user on the confirm step with a typed [TokenError] and NEVER reaches the network probe.
|
||||
*
|
||||
* The token itself never enters [PairingUiState] — it lives in the screen's own field and is passed in
|
||||
* per call, so no state snapshot, log or crash report can carry it.
|
||||
*
|
||||
* @param hostStore where a successfully-paired [Host] is saved.
|
||||
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
|
||||
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
|
||||
* @param tokenStore per-host access-token storage (production: the Tink/AndroidKeystore store).
|
||||
* @param authProber the `POST /auth` validation seam ([accessTokenProber] in production).
|
||||
* @param newId host-id allocator (default random UUID; deterministic in tests).
|
||||
*/
|
||||
public class PairingViewModel(
|
||||
private val hostStore: HostStore,
|
||||
private val prober: PairingProber,
|
||||
private val hasDeviceCert: suspend () -> Boolean,
|
||||
private val tokenStore: AccessTokenStore,
|
||||
private val authProber: AccessTokenProber,
|
||||
private val newId: () -> String = { UUID.randomUUID().toString() },
|
||||
) {
|
||||
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
|
||||
@@ -111,22 +132,23 @@ public class PairingViewModel(
|
||||
/**
|
||||
* Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be
|
||||
* true or this re-arms the acknowledge reminder without touching the network (RULE 3).
|
||||
* [accessToken] is the (optional) `WEBTERM_TOKEN` the user typed — blank means "this host has none".
|
||||
*/
|
||||
public fun confirm(acknowledgedPublicRisk: Boolean = false) {
|
||||
public fun confirm(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
runProbe(candidate, acknowledgedPublicRisk, accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as
|
||||
* [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying.
|
||||
*/
|
||||
public fun retry(acknowledgedPublicRisk: Boolean = false) {
|
||||
public fun retry(acknowledgedPublicRisk: Boolean = false, accessToken: String = "") {
|
||||
val candidate = _uiState.value.candidate() ?: return
|
||||
runProbe(candidate, acknowledgedPublicRisk)
|
||||
runProbe(candidate, acknowledgedPublicRisk, accessToken)
|
||||
}
|
||||
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) {
|
||||
val tier = candidate.tier
|
||||
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
|
||||
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||
@@ -147,19 +169,79 @@ public class PairingViewModel(
|
||||
return@launch
|
||||
}
|
||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store).
|
||||
if (accessToken.isNotBlank() && !establishToken(candidate, accessToken)) return@launch
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = tier,
|
||||
error = result.error,
|
||||
message = PairingCopy.describe(result.error, tier),
|
||||
)
|
||||
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host.
|
||||
* Returns true when pairing may continue (accepted-and-stored, OR the host has no auth at all);
|
||||
* false after publishing the matching [TokenError] / failure state.
|
||||
*/
|
||||
private suspend fun establishToken(candidate: Candidate, token: String): Boolean {
|
||||
val outcome = try {
|
||||
authProber.validate(candidate.endpoint, token)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (error: Throwable) {
|
||||
// A network-level failure is not a token problem — classify it like any other probe error.
|
||||
onProbeFailure(candidate, PairingError.classify(error, candidate.endpoint))
|
||||
return false
|
||||
}
|
||||
return when (outcome) {
|
||||
AuthProbeResult.Accepted -> storeToken(candidate, token)
|
||||
// 204 with no Set-Cookie: the host has no auth. Store NOTHING (never "assume authenticated")
|
||||
// and carry on — an open host pairs exactly as it did before this feature.
|
||||
AuthProbeResult.AuthDisabled -> true
|
||||
AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID)
|
||||
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.TOO_MANY_ATTEMPTS)
|
||||
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
|
||||
is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storeToken(candidate: Candidate, token: String): Boolean {
|
||||
val stored = runCatching { tokenStore.put(candidate.endpoint, token) }
|
||||
return when {
|
||||
stored.getOrNull() == true -> true
|
||||
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT).
|
||||
stored.isSuccess -> failToken(candidate, TokenError.MALFORMED)
|
||||
// A throw ⇒ encrypted storage failed; never continue with a token we could not keep.
|
||||
else -> failToken(candidate, TokenError.STORE_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */
|
||||
private fun failToken(candidate: Candidate, error: TokenError): Boolean {
|
||||
_uiState.value = PairingUiState.Confirming(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
tokenError = error,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
private fun onProbeFailure(candidate: Candidate, error: PairingError) {
|
||||
// The host demands a token we do not have → back to the confirm step to enter one.
|
||||
if (error == PairingError.AccessTokenRequired) {
|
||||
failToken(candidate, TokenError.REQUIRED)
|
||||
return
|
||||
}
|
||||
_uiState.value = PairingUiState.Failed(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
error = error,
|
||||
message = PairingCopy.describe(error, candidate.tier),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
|
||||
if (host == null) {
|
||||
@@ -186,6 +268,21 @@ public fun interface PairingProber {
|
||||
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* The `POST /auth` access-token validation seam ([probeAccessToken]); faked in tests. Kept separate from
|
||||
* [PairingProber] so the ORDER (validate+store, then probe) is observable in a unit test.
|
||||
*/
|
||||
public fun interface AccessTokenProber {
|
||||
public suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Production [AccessTokenProber]: the frozen one-shot `POST /auth` probe over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport]. The token travels in the JSON body only — never a URL, never a log.
|
||||
*/
|
||||
public fun accessTokenProber(http: HttpTransport): AccessTokenProber =
|
||||
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, http, token) }
|
||||
|
||||
/**
|
||||
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
|
||||
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
|
||||
@@ -193,8 +290,12 @@ public fun interface PairingProber {
|
||||
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
|
||||
* I/O on a UI thread.
|
||||
*/
|
||||
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
|
||||
public fun pairingProber(
|
||||
http: HttpTransport,
|
||||
ws: TermTransport,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) }
|
||||
|
||||
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -265,6 +366,11 @@ public sealed interface PairingUiState {
|
||||
val name: String,
|
||||
val tier: WarningTier,
|
||||
val awaitingAck: Boolean = false,
|
||||
/**
|
||||
* Set when the access-token step failed (or the host demands one). The token VALUE is never
|
||||
* carried here — only why it went wrong (§1.1: a secret never enters app state).
|
||||
*/
|
||||
val tokenError: TokenError? = null,
|
||||
) : PairingUiState
|
||||
|
||||
/** The two-step probe is running against [endpoint]. */
|
||||
@@ -295,6 +401,30 @@ public enum class EntryError {
|
||||
INVALID_URL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the access-token step failed. Rendered next to the token field on the confirm step, so the user can
|
||||
* fix it in place (B5 / ios-completion §1.1).
|
||||
*/
|
||||
public enum class TokenError {
|
||||
/** The host answered 401 to the probe: it HAS a token configured and we presented none/a wrong one. */
|
||||
REQUIRED,
|
||||
|
||||
/** `POST /auth` → 401: the typed token is wrong. */
|
||||
INVALID,
|
||||
|
||||
/** `POST /auth` → 429: the server's 10/min/IP limiter tripped. */
|
||||
TOO_MANY_ATTEMPTS,
|
||||
|
||||
/** The typed token cannot be a server token at all (charset / 16–512 length). No request was sent. */
|
||||
MALFORMED,
|
||||
|
||||
/** `POST /auth` answered something unexpected — the token could not be verified either way. */
|
||||
UNVERIFIABLE,
|
||||
|
||||
/** The token was correct but encrypted on-device storage refused to keep it. */
|
||||
STORE_FAILED,
|
||||
}
|
||||
|
||||
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
|
||||
private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier)
|
||||
|
||||
@@ -308,6 +438,22 @@ private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
|
||||
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
|
||||
internal object PairingCopy {
|
||||
/** Inert, app-authored copy for each [TokenError]. Never echoes the token itself. */
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.REQUIRED ->
|
||||
"该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。"
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试。"
|
||||
TokenError.TOO_MANY_ATTEMPTS ->
|
||||
"尝试过多,服务器已限流(每分钟 10 次)。请稍后再试。"
|
||||
TokenError.MALFORMED ->
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。"
|
||||
TokenError.UNVERIFIABLE ->
|
||||
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
|
||||
TokenError.STORE_FAILED ->
|
||||
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
|
||||
}
|
||||
|
||||
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
||||
is PairingError.HostUnreachable ->
|
||||
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
||||
@@ -321,6 +467,7 @@ internal object PairingCopy {
|
||||
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
|
||||
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
|
||||
else "TLS 握手失败:无法验证服务器证书。"
|
||||
PairingError.AccessTokenRequired -> describeToken(TokenError.REQUIRED)
|
||||
PairingError.Timeout ->
|
||||
"连接超时。主机可能不可达,或响应过慢。"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package wang.yaojia.webterm.wiring
|
||||
|
||||
import dagger.Lazy
|
||||
import wang.yaojia.webterm.api.routes.ApiClient
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import javax.inject.Inject
|
||||
@@ -23,6 +24,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
public class ApiClientFactory @Inject constructor(
|
||||
private val http: Lazy<HttpTransport>,
|
||||
private val tokens: AccessTokenSource,
|
||||
) {
|
||||
/**
|
||||
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
|
||||
@@ -32,6 +34,14 @@ public class ApiClientFactory @Inject constructor(
|
||||
http.get()
|
||||
}
|
||||
|
||||
/** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */
|
||||
public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get())
|
||||
/**
|
||||
* Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read.
|
||||
*
|
||||
* Every client is handed the shared [AccessTokenSource], so EVERY REST route — including the
|
||||
* read-only GETs and the push-decision POSTs — presents this host's access token when one is stored
|
||||
* (B5 / ios-completion §1.1). The token is read per request, so pairing a host mid-run takes effect
|
||||
* without rebuilding anything.
|
||||
*/
|
||||
public fun create(endpoint: HostEndpoint): ApiClient =
|
||||
ApiClient(endpoint = endpoint, http = http.get(), tokens = tokens)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import dagger.Lazy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.pairing.runPairingProbe
|
||||
import wang.yaojia.webterm.hostregistry.AccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.LastSessionStore
|
||||
import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.viewmodels.AccessTokenProber
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.viewmodels.accessTokenProber
|
||||
import wang.yaojia.webterm.api.pairing.probeAccessToken
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import javax.inject.Inject
|
||||
@@ -46,6 +51,11 @@ import javax.inject.Singleton
|
||||
public class AppEnvironment @Inject constructor(
|
||||
public val hostStore: HostStore,
|
||||
public val lastSessionStore: LastSessionStore,
|
||||
/**
|
||||
* B5 · per-host access tokens (`WEBTERM_TOKEN`), Tink/AndroidKeystore-encrypted. The pairing screen
|
||||
* WRITES it; the transports read the same instance through [AccessTokenSource] (see `AuthModule`).
|
||||
*/
|
||||
public val accessTokenStore: AccessTokenStore,
|
||||
public val apiClientFactory: ApiClientFactory,
|
||||
public val sessionEngineFactory: SessionEngineFactory,
|
||||
public val coldStartPolicy: ColdStartPolicy,
|
||||
@@ -80,7 +90,19 @@ public class AppEnvironment @Inject constructor(
|
||||
* after [warmUp].
|
||||
*/
|
||||
public fun buildPairingProber(): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) }
|
||||
PairingProber { endpoint ->
|
||||
// The probe's HTTP legs authenticate from the SAME store the WS transport reads, so a token
|
||||
// stored a moment earlier by the pairing flow applies to all three legs (B5 / §1.1).
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `POST /auth` access-token validation prober (B5). Like [buildPairingProber], the shared
|
||||
* transport is resolved lazily INSIDE the call so building this on the UI thread does no mTLS/OkHttp
|
||||
* work.
|
||||
*/
|
||||
public fun buildAccessTokenProber(): AccessTokenProber =
|
||||
AccessTokenProber { endpoint, token -> probeAccessToken(endpoint, httpTransportLazy.get(), token) }
|
||||
|
||||
/**
|
||||
* Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving
|
||||
|
||||
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
29
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
B5 / ios-completion §1.1 — the app holds SECRET material on device: the per-host access token
|
||||
(`WEBTERM_TOKEN`, Tink-encrypted in webterm_access_token_prefs) and the mTLS device identity
|
||||
(AndroidKeyStore key + Tink-encrypted cert blob). None of it may leave the device.
|
||||
|
||||
`android:allowBackup="false"` already opts out of cloud backup / adb backup on every API level; this
|
||||
file additionally covers API 31+ cloud-backup and device-to-device TRANSFER, where a plain
|
||||
allowBackup=false does NOT by itself block transfer. Both sections exclude everything.
|
||||
|
||||
(The AndroidKeyStore private key is non-exportable regardless, so a transferred blob would be
|
||||
undecryptable anyway — this is defence in depth, and it keeps the intent explicit.)
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" />
|
||||
<exclude domain="file" />
|
||||
<exclude domain="database" />
|
||||
<exclude domain="sharedpref" />
|
||||
<exclude domain="external" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -109,4 +109,27 @@ class ReconnectBannerTest {
|
||||
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
|
||||
assertEquals(connecting, bannerModel(connecting, Output("some bytes")))
|
||||
}
|
||||
|
||||
// ── 5. B5 · a 401 upgrade is terminal AND offers no new session ───────────────
|
||||
|
||||
@Test
|
||||
fun `an unauthorized failure is terminal with no spinner and no new-session action`() {
|
||||
val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
|
||||
|
||||
val failed = model as BannerModel.Failed
|
||||
assertEquals(FailureReason.UNAUTHORIZED, failed.reason)
|
||||
assertFalse(failed.showsSpinner, "a 401 is permanent — never show a retry spinner")
|
||||
assertFalse(
|
||||
failed.offersNewSession,
|
||||
"a new session would 401 too; the fix is the access token, not another session",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unauthorized failure outranks later transient events`() {
|
||||
val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
|
||||
|
||||
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Connecting)))
|
||||
assertEquals(failed, bannerModel(failed, Connection(ConnectionState.Reconnecting(1, 2.seconds))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import wang.yaojia.webterm.api.models.CommitResult
|
||||
import wang.yaojia.webterm.api.models.GitWriteOutcome
|
||||
import wang.yaojia.webterm.api.models.PushResult
|
||||
import wang.yaojia.webterm.api.models.StageResult
|
||||
import wang.yaojia.webterm.testsupport.FakeHttpTransport
|
||||
import wang.yaojia.webterm.wire.AccessTokenSource
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
/**
|
||||
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
|
||||
@@ -291,4 +294,35 @@ class DiffViewModelTest {
|
||||
assertTrue(DiffUiState(canWrite = true).writeEnabled)
|
||||
assertFalse(DiffUiState(canWrite = true, base = "main").writeEnabled)
|
||||
}
|
||||
|
||||
// ── B5 · the hand-built diff route carries the access-token cookie ───────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the real diff fetcher stamps the auth cookie and never an Origin`() = runTest {
|
||||
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||
val http = FakeHttpTransport()
|
||||
val url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0"
|
||||
http.queueSuccess(url = url, body = """{"files":[]}""".toByteArray())
|
||||
val fetcher = HttpDiffFetcher(endpoint, http, AccessTokenSource { "0123456789abcdefTOKEN" })
|
||||
|
||||
fetcher.fetch("/repo", staged = false, base = null)
|
||||
|
||||
val request = http.recordedRequests.single()
|
||||
assertEquals("webterm_auth=0123456789abcdefTOKEN", request.headers["Cookie"])
|
||||
assertFalse(request.headers.containsKey("Origin"), "the diff route is read-only")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the real diff fetcher sends no cookie for a host without a token`() = runTest {
|
||||
val endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
|
||||
val http = FakeHttpTransport()
|
||||
http.queueSuccess(
|
||||
url = "http://10.0.0.5:3000/projects/diff?path=%2Frepo&staged=0",
|
||||
body = """{"files":[]}""".toByteArray(),
|
||||
)
|
||||
|
||||
HttpDiffFetcher(endpoint, http).fetch("/repo", staged = false, base = null)
|
||||
|
||||
assertFalse(http.recordedRequests.single().headers.containsKey("Cookie"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertInstanceOf
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.pairing.AuthProbeResult
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* B5 · the access-token half of pairing (FROZEN contract, ios-completion §1.1). The pairing screen is
|
||||
* where a token is entered, validated with `POST /auth`, and stored — so this pins:
|
||||
* - **order**: the token is validated and stored BEFORE the two-step probe runs, because the probe's
|
||||
* own HTTP+WS legs must already be able to authenticate;
|
||||
* - the four `/auth` outcomes → the right UI state (and, for 204-no-Set-Cookie, **no** token stored);
|
||||
* - a wrong token / rate-limit keeps the user ON the confirm step with a typed [TokenError] (the field
|
||||
* is right there) instead of dropping them into a generic failure;
|
||||
* - a host that answers 401 to the probe asks for a token ([TokenError.REQUIRED]);
|
||||
* - the token NEVER appears in the UI state (no accidental leak into a state dump / crash report).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PairingTokenViewModelTest {
|
||||
|
||||
private companion object {
|
||||
const val LAN = "http://10.0.0.5:3000"
|
||||
const val TOKEN = "0123456789abcdefTOKEN"
|
||||
}
|
||||
|
||||
/** Records every `/auth` validation so ordering vs the pairing probe is directly assertable. */
|
||||
private class FakeAuthProber(var result: (String) -> AuthProbeResult) : AccessTokenProber {
|
||||
val calls = mutableListOf<String>()
|
||||
override suspend fun validate(endpoint: HostEndpoint, token: String): AuthProbeResult {
|
||||
calls += token
|
||||
return result(token)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeProber(var result: (HostEndpoint) -> PairingProbeResult) : PairingProber {
|
||||
val calls = mutableListOf<HostEndpoint>()
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
|
||||
calls += endpoint
|
||||
return result(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun endpoint(url: String = LAN): HostEndpoint =
|
||||
requireNotNull(HostEndpoint.fromBaseUrl(url))
|
||||
|
||||
private fun newVm(
|
||||
prober: PairingProber,
|
||||
authProber: AccessTokenProber,
|
||||
tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(),
|
||||
store: InMemoryHostStore = InMemoryHostStore(),
|
||||
) = PairingViewModel(
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { false },
|
||||
tokenStore = tokenStore,
|
||||
authProber = authProber,
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
// ── happy path: validate → store → probe ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a correct token is validated, stored, and only THEN is the host probed`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val order = mutableListOf<String>()
|
||||
val authProber = FakeAuthProber { order += "auth"; AuthProbeResult.Accepted }
|
||||
val prober = FakeProber { order += "probe"; PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val hosts = InMemoryHostStore()
|
||||
val vm = newVm(prober, authProber, tokens, hosts)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertEquals(listOf("auth", "probe"), order, "the probe must be able to authenticate itself")
|
||||
assertEquals(TOKEN, tokens.tokenFor(endpoint()), "an accepted token is persisted per host")
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(1, hosts.loadAll().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank token skips the auth probe entirely so an open LAN host pairs as before`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Accepted }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, authProber, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
|
||||
assertTrue(authProber.calls.isEmpty(), "no token typed ⇒ never call POST /auth")
|
||||
assertEquals(1, prober.calls.size)
|
||||
assertNull(tokens.tokenFor(endpoint()))
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
// ── the four frozen /auth outcomes ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `204 without Set-Cookie means auth is disabled - nothing is stored but pairing continues`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.AuthDisabled }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, authProber, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertNull(tokens.tokenFor(endpoint()), "a host without auth must not get a stored token")
|
||||
assertEquals(1, prober.calls.size, "the host is open — pairing proceeds")
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong token keeps the user on the confirm step with an INVALID token error`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.InvalidToken }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, authProber, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.INVALID, state.tokenError)
|
||||
assertTrue(prober.calls.isEmpty(), "a wrong token must not proceed to the probe")
|
||||
assertNull(tokens.tokenFor(endpoint()), "a rejected token is never stored")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rate-limited auth attempt surfaces TOO_MANY_ATTEMPTS`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.RateLimited }
|
||||
val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.TOO_MANY_ATTEMPTS, state.tokenError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed token is reported without any network call`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Malformed }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val vm = newVm(prober, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = "short")
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.MALFORMED, state.tokenError)
|
||||
assertTrue(prober.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unexpected auth status never counts as success`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Unexpected(302) }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val vm = newVm(prober, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.UNVERIFIABLE, state.tokenError)
|
||||
assertTrue(prober.calls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a transport failure during auth validation maps to the normal pairing failure copy`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val authProber = FakeAuthProber { throw IOException("connection refused") }
|
||||
val vm = newVm(FakeProber { PairingProbeResult.Success(it) }, authProber)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
// ── discovery: the host demands a token we do not have ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a probe that 401s asks for an access token`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) }
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted })
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
|
||||
val state = assertInstanceOf(PairingUiState.Confirming::class.java, vm.uiState.value)
|
||||
assertEquals(TokenError.REQUIRED, state.tokenError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrying with the token now typed pairs successfully`() = runTest(UnconfinedTestDispatcher()) {
|
||||
var demandToken = true
|
||||
val prober = FakeProber {
|
||||
if (demandToken) PairingProbeResult.Failure(PairingError.AccessTokenRequired)
|
||||
else PairingProbeResult.Success(it)
|
||||
}
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
demandToken = false
|
||||
vm.retry(accessToken = TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(TOKEN, tokens.tokenFor(endpoint()))
|
||||
}
|
||||
|
||||
// ── secrecy ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the token never appears in the UI state`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val vm = newVm(
|
||||
FakeProber { PairingProbeResult.Failure(PairingError.AccessTokenRequired) },
|
||||
FakeAuthProber { AuthProbeResult.Accepted },
|
||||
)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertTrue(
|
||||
!vm.uiState.value.toString().contains(TOKEN),
|
||||
"a secret must not be reachable through a state snapshot / toString",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every token error has actionable copy`() {
|
||||
TokenError.entries.forEach { error ->
|
||||
assertTrue(PairingCopy.describeToken(error).isNotBlank(), "copy for $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.api.pairing.PairingError
|
||||
import wang.yaojia.webterm.api.pairing.PairingProbeResult
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
|
||||
@@ -44,6 +45,11 @@ class PairingViewModelTest {
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert },
|
||||
// B5: these tests cover the NO-token paths — an empty store plus a prober that must never be
|
||||
// called (a blank token skips `POST /auth` entirely). The token paths live in
|
||||
// PairingTokenViewModelTest.
|
||||
tokenStore = InMemoryAccessTokenStore(),
|
||||
authProber = { _, _ -> error("POST /auth must not be called when no token was typed") },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user