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:
@@ -23,17 +23,20 @@
|
||||
never in backups/cloud"). Auto Backup defaults to ON and would sweep up EVERY app-private
|
||||
file: the `webterm` Preferences DataStore (which holds the sealed `webterm_auth` cookie — a
|
||||
full-shell credential the server keeps alive for 30 days) and the Tink keyset/ciphertext pref
|
||||
files. `dataExtractionRules`/`fullBackupContent` were rejected because they are allow-by-
|
||||
default: every store added later is backed up until someone remembers to exclude it, and a
|
||||
silent miss here is an off-device credential leak. Nothing in this app has restore value that
|
||||
justifies that risk — re-pairing a host is a QR scan, and the hosts are LAN-local anyway.
|
||||
NOTE: this attribute is Auto Backup (cloud). Android 12+ DEVICE-TO-DEVICE transfer is governed
|
||||
by a `dataExtractionRules` <device-transfer> block, which needs an @xml resource (tracked
|
||||
separately — see the orchestrator note). Do not set this back to true. -->
|
||||
files. Nothing in this app has restore value that justifies that risk — re-pairing a host is
|
||||
a QR scan, and the hosts are LAN-local anyway.
|
||||
`allowBackup` alone only covers Auto Backup (cloud) / adb backup. Android 12+ DEVICE-TO-DEVICE
|
||||
transfer is governed by a `dataExtractionRules` <device-transfer> block, which needs an @xml
|
||||
resource — @xml/data_extraction_rules now supplies it, and it is deny-everything in BOTH
|
||||
sections rather than an exclusion list, so a store added later is excluded by default instead
|
||||
of leaking until someone remembers it. `fullBackupContent="false"` states the same for the
|
||||
pre-31 path. Do not set any of these back to true. -->
|
||||
<application
|
||||
android:name=".WebTermApp"
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
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
|
||||
}
|
||||
@@ -13,6 +13,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
|
||||
@@ -81,10 +82,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)),
|
||||
|
||||
@@ -41,6 +41,7 @@ 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
|
||||
@@ -70,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).
|
||||
@@ -113,7 +120,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)
|
||||
@@ -247,10 +254,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).
|
||||
@@ -268,6 +281,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 })
|
||||
@@ -278,13 +299,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,18 +1,28 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -40,24 +50,56 @@ import java.util.UUID
|
||||
*
|
||||
* On probe success the validated host is persisted via [HostStore.upsert].
|
||||
*
|
||||
* ### The `WEBTERM_TOKEN` gate (server `src/http/auth.ts`)
|
||||
* A token-gated host answers the probe's unauthenticated `GET /live-sessions` with **401**, which the
|
||||
* frozen probe taxonomy can only report as [PairingError.HttpOkButNotWebTerminal] ("端口对吗?") — so
|
||||
* without this flow such a host is impossible to pair AND the copy is misleading. On that ambiguous
|
||||
* failure the ViewModel asks [PairingProber.requiresAccessToken]; a gated host routes to
|
||||
* [PairingUiState.TokenRequired], and [submitAccessToken] posts the token (`POST /auth`) so the shared
|
||||
* cookie jar picks up the `webterm_auth` cookie, then resumes pairing through the same choke point.
|
||||
* The token is a SHELL credential: it is a parameter, never a field and never part of any UI state.
|
||||
* ### The `WEBTERM_TOKEN` gate has TWO ways in, and both end in the same place
|
||||
* A token-gated host can be met either way round, so both are supported:
|
||||
* - **Typed up front.** The confirm step has an optional token field; [confirm]/[retry] carry it into
|
||||
* RULE 5 below (validate with `POST /auth`, store, then probe).
|
||||
* - **Discovered by the probe.** A host the user did not know was gated answers the probe's
|
||||
* unauthenticated `GET /live-sessions` with **401**. When the probe classifies that as
|
||||
* [PairingError.AccessTokenRequired] the user goes back to the confirm step with
|
||||
* [TokenError.REQUIRED] under the field. An older/ambiguous host instead collapses the 401 into
|
||||
* [PairingError.HttpOkButNotWebTerminal] ("端口对吗?"), which is actively misleading — so that ONE
|
||||
* case is re-checked against [PairingProber.requiresAccessToken] and, if the host really is gated,
|
||||
* routed to the dedicated [PairingUiState.TokenRequired] prompt. [submitAccessToken] posts the token
|
||||
* (`POST /auth`), which lands the `webterm_auth` cookie in the shared cookie jar, keeps the token in
|
||||
* [tokenStore] so the WS upgrade's own `Cookie` header can carry it too, then resumes pairing through
|
||||
* the same choke point.
|
||||
* Either way the token is a SHELL credential: it is a parameter, never a field and never part of any UI
|
||||
* state.
|
||||
*
|
||||
* ### 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.
|
||||
*
|
||||
* ### RULE 6 — a pairing that does not end in [PairingUiState.Paired] strands no secret (F2)
|
||||
* Storing before probing means the store can be left holding a token for a host that never paired (wrong
|
||||
* port, unreachable, user backs out). So every non-paired exit — probe failure, a throw, and cancellation
|
||||
* by [reset] or by a superseding attempt — rolls the store back to exactly what it held before this
|
||||
* attempt ([TokenAttempt]): the previous token for a re-pair, nothing at all for a first pairing. The
|
||||
* rollback runs under [NonCancellable] so an abandoned attempt still cleans up, and the next attempt
|
||||
* `join`s the one it supersedes so the two never race on the same key.
|
||||
*
|
||||
* 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())
|
||||
@@ -118,30 +160,34 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the host's shared access token (`WEBTERM_TOKEN`). Only meaningful from
|
||||
* [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never post a
|
||||
* credential to a host the user has moved on from.
|
||||
* Submit the host's shared access token (`WEBTERM_TOKEN`) from the token prompt. Only meaningful
|
||||
* from [PairingUiState.TokenRequired] — anywhere else it is a no-op, so a stale UI event can never
|
||||
* post a credential to a host the user has moved on from.
|
||||
*
|
||||
* [token] is passed straight through to the prober and is NEVER stored: no field, no UI state, no
|
||||
* log. On acceptance the shared cookie jar holds `webterm_auth` and pairing resumes through the same
|
||||
* choke point as [confirm]/[retry] (so RULE 4's cert-gate still applies); every other outcome
|
||||
* re-arms the prompt with a distinguishable reason and NEVER auto-retries (429 included).
|
||||
* This is the DISCOVERED half of the gate; the typed-up-front half is [confirm]'s `accessToken`
|
||||
* (RULE 5). [token] goes straight to the prober's `POST /auth` and is never held in a field or in
|
||||
* UI state. On acceptance the shared cookie jar holds `webterm_auth` AND the token is kept in
|
||||
* [tokenStore] — so the WS upgrade's own `Cookie` header authenticates exactly as it would for a
|
||||
* typed token — and pairing resumes through the same [performProbe] choke point as [confirm]/[retry]
|
||||
* (so RULE 4's cert-gate still applies, under RULE 6's rollback). Every other outcome re-arms the
|
||||
* prompt with a distinguishable reason and NEVER auto-retries (429 included).
|
||||
*/
|
||||
public fun submitAccessToken(token: String) {
|
||||
val state = _uiState.value
|
||||
@@ -149,14 +195,22 @@ public class PairingViewModel(
|
||||
if (token.isBlank()) return
|
||||
val candidate = Candidate(state.endpoint, state.name, state.tier)
|
||||
val scope = scope ?: return
|
||||
probeJob?.cancel()
|
||||
val superseded = probeJob
|
||||
superseded?.cancel()
|
||||
probeJob = scope.launch {
|
||||
superseded?.join() // same reason as runProbe: never race a superseded attempt's rollback.
|
||||
_uiState.value = PairingUiState.TokenSubmitting(candidate.endpoint, candidate.name, candidate.tier)
|
||||
when (prober.submitAccessToken(candidate.endpoint, token)) {
|
||||
// Straight into the probe body: RULE 3 is already satisfied by construction (reaching the
|
||||
// prompt required a probe, which for a public host required the explicit ack — and an ack
|
||||
// cannot be un-given), while RULE 4's cert-gate lives INSIDE performProbe and re-applies.
|
||||
TokenSubmission.Accepted -> performProbe(candidate)
|
||||
// This submit WAS the `POST /auth` validation, so RULE 5 only has to KEEP the token.
|
||||
TokenSubmission.Accepted -> performProbe(candidate) {
|
||||
writeToken(candidate, token) { error -> promptForToken(candidate, error); null }
|
||||
}
|
||||
// 204 with no Set-Cookie: the host has no auth at all (FROZEN §1.1). Store NOTHING and
|
||||
// never read it as "authenticated" — same rule as `establishToken`'s AuthDisabled.
|
||||
TokenSubmission.AuthDisabled -> performProbe(candidate) { TokenAttempt.NONE }
|
||||
TokenSubmission.Rejected -> promptForToken(candidate, TokenError.INVALID)
|
||||
TokenSubmission.RateLimited -> promptForToken(candidate, TokenError.RATE_LIMITED)
|
||||
is TokenSubmission.Unreachable -> promptForToken(candidate, TokenError.UNREACHABLE)
|
||||
@@ -165,7 +219,7 @@ public class PairingViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
|
||||
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean, accessToken: String) {
|
||||
// RULE 3 — public host: no probe until the risk is explicitly acknowledged. This is the
|
||||
// pre-network UI gate, so it lives here (the entry point the user drives), not in performProbe.
|
||||
if (candidate.tier.needsExplicitAck && !acknowledgedPublicRisk) {
|
||||
@@ -178,17 +232,31 @@ public class PairingViewModel(
|
||||
return
|
||||
}
|
||||
val scope = scope ?: return
|
||||
probeJob?.cancel()
|
||||
probeJob = scope.launch { performProbe(candidate) }
|
||||
val superseded = probeJob
|
||||
superseded?.cancel()
|
||||
probeJob = scope.launch {
|
||||
// Wait out the attempt this one replaces: its RULE 6 rollback must finish before we read or
|
||||
// write the same store key, or the two attempts race and the loser's undo wins.
|
||||
superseded?.join()
|
||||
// RULE 5 — validate + store the token BEFORE probing (the probe authenticates from the store).
|
||||
performProbe(candidate) {
|
||||
if (accessToken.isBlank()) TokenAttempt.NONE else establishToken(candidate, accessToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE probe body — reached from [confirm]/[retry] via [runProbe] and from an accepted access
|
||||
* token. Holds RULE 4 (the cert-gate) so BOTH entry points hit it; RULE 3 (the public-risk ack) is a
|
||||
* token ([submitAccessToken]). Holds RULE 4 (the cert-gate) so BOTH entry points hit it BEFORE any
|
||||
* network I/O, then RULE 5's [tokenStep] and RULE 6's rollback; RULE 3 (the public-risk ack) is a
|
||||
* pre-network UI gate and stays in [runProbe]. Runs inside the caller's job (never re-assigns
|
||||
* [probeJob], which would cancel the coroutine it is called from).
|
||||
*
|
||||
* @param tokenStep the RULE 5 step for this entry point — validate+store a typed token, or merely
|
||||
* store one the prompt already validated. Returns the [TokenAttempt] to undo, or **null** after
|
||||
* publishing why pairing stops (meaning "do not probe").
|
||||
*/
|
||||
private suspend fun performProbe(candidate: Candidate) {
|
||||
private suspend fun performProbe(candidate: Candidate, tokenStep: suspend () -> TokenAttempt?) {
|
||||
val tier = candidate.tier
|
||||
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry and the post-token resume both
|
||||
// re-hit this same guard.
|
||||
@@ -197,18 +265,122 @@ public class PairingViewModel(
|
||||
return
|
||||
}
|
||||
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> onProbeFailure(candidate, result.error)
|
||||
val attempt = tokenStep() ?: return
|
||||
// RULE 6 — anything short of Paired puts the store back the way this attempt found it.
|
||||
val paired = try {
|
||||
when (val result = prober.probe(candidate.endpoint)) {
|
||||
is PairingProbeResult.Success -> onProbeSuccess(candidate)
|
||||
is PairingProbeResult.Failure -> {
|
||||
onProbeFailure(candidate, result.error)
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
// Cancelled (reset / a newer attempt) or a transport throw — the rule is the same, and
|
||||
// NonCancellable is what lets an already-cancelled attempt still clean up after itself.
|
||||
withContext(NonCancellable) { rollBackToken(candidate, attempt) }
|
||||
throw error
|
||||
}
|
||||
if (!paired) rollBackToken(candidate, attempt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate [token] with `POST /auth` and, if the server accepted it, persist it for this host.
|
||||
* Returns the [TokenAttempt] to undo should pairing not complete (RULE 6) — [TokenAttempt.NONE] when
|
||||
* the host turned out to have no auth at all — or **null** after publishing the matching
|
||||
* [TokenError] / failure state, meaning "stop, do not probe".
|
||||
*/
|
||||
private suspend fun establishToken(candidate: Candidate, token: String): TokenAttempt? {
|
||||
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 null
|
||||
}
|
||||
return when (outcome) {
|
||||
AuthProbeResult.Accepted -> writeToken(candidate, token) { failToken(candidate, it) }
|
||||
// 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 -> TokenAttempt.NONE
|
||||
AuthProbeResult.InvalidToken -> failToken(candidate, TokenError.INVALID)
|
||||
AuthProbeResult.RateLimited -> failToken(candidate, TokenError.RATE_LIMITED)
|
||||
AuthProbeResult.Malformed -> failToken(candidate, TokenError.MALFORMED)
|
||||
is AuthProbeResult.Unexpected -> failToken(candidate, TokenError.UNVERIFIABLE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed probe. [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a
|
||||
* `WEBTERM_TOKEN`-gated host's 401 collapses into — so that one case is re-checked against the token
|
||||
* gate before the misleading "wrong port?" copy is shown. Every other error is reported verbatim.
|
||||
* Persist [token], remembering what the store held before so RULE 6 can put it back. [onFailure]
|
||||
* publishes the reason in whichever step the caller is showing (the confirm field, or the token
|
||||
* prompt) and returns null, so a token we could not keep never reaches the probe.
|
||||
*/
|
||||
private suspend fun writeToken(
|
||||
candidate: Candidate,
|
||||
token: String,
|
||||
onFailure: (TokenError) -> TokenAttempt?,
|
||||
): TokenAttempt? {
|
||||
return try {
|
||||
val previous = tokenStore.tokenFor(candidate.endpoint)
|
||||
// put() == false ⇒ the token failed the client-side rule (unreachable after a server ACCEPT).
|
||||
if (!tokenStore.put(candidate.endpoint, token)) onFailure(TokenError.MALFORMED)
|
||||
else TokenAttempt(stored = true, previous = previous)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// A throw ⇒ encrypted storage failed; never continue with a token we could not keep.
|
||||
onFailure(TokenError.STORE_FAILED)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo [attempt]'s write: restore the previous token (a failed re-pair must not cost the user the
|
||||
* one that was working) or drop the key entirely when this host had none. Never publishes state —
|
||||
* the caller has already published the reason pairing stopped.
|
||||
*/
|
||||
private suspend fun rollBackToken(candidate: Candidate, attempt: TokenAttempt) {
|
||||
if (!attempt.stored) return
|
||||
try {
|
||||
val previous = attempt.previous
|
||||
if (previous == null) tokenStore.remove(candidate.endpoint)
|
||||
else tokenStore.put(candidate.endpoint, previous)
|
||||
} catch (cancel: CancellationException) {
|
||||
throw cancel
|
||||
} catch (_: Throwable) {
|
||||
// Best effort: encrypted storage just failed on the undo. There is no recovery beyond this,
|
||||
// and surfacing it would replace the pairing error the user actually needs to read.
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish a token-specific error, keeping the user ON the confirm step (the field is there). */
|
||||
private fun failToken(candidate: Candidate, error: TokenError): TokenAttempt? {
|
||||
_uiState.value = PairingUiState.Confirming(
|
||||
endpoint = candidate.endpoint,
|
||||
name = candidate.name,
|
||||
tier = candidate.tier,
|
||||
tokenError = error,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed probe, with TWO token-gate readings:
|
||||
* - [PairingError.AccessTokenRequired] — the probe saw the gate's own 401, so the user goes back to
|
||||
* the confirm step with the token field and [TokenError.REQUIRED];
|
||||
* - [PairingError.HttpOkButNotWebTerminal] is ambiguous — it is ALSO what a `WEBTERM_TOKEN`-gated
|
||||
* host's 401 collapses into on a host the taxonomy cannot classify — so that one case is
|
||||
* re-checked against [PairingProber.requiresAccessToken] and, when the host really is gated,
|
||||
* routed to the dedicated prompt instead of the misleading "wrong port?" copy.
|
||||
* Every other error is reported verbatim.
|
||||
*/
|
||||
private suspend 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
|
||||
}
|
||||
if (error == PairingError.HttpOkButNotWebTerminal && prober.requiresAccessToken(candidate.endpoint)) {
|
||||
promptForToken(candidate, error = null)
|
||||
return
|
||||
@@ -222,6 +394,7 @@ public class PairingViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/** Publish the dedicated token prompt (the discovered-gate step), optionally with a reason. */
|
||||
private fun promptForToken(candidate: Candidate, error: TokenError?) {
|
||||
_uiState.value = PairingUiState.TokenRequired(
|
||||
endpoint = candidate.endpoint,
|
||||
@@ -231,7 +404,8 @@ public class PairingViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun onProbeSuccess(candidate: Candidate) {
|
||||
/** Persist the paired host. Returns false when it could not be paired (so RULE 6 undoes the token). */
|
||||
private suspend fun onProbeSuccess(candidate: Candidate): Boolean {
|
||||
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
|
||||
if (host == null) {
|
||||
// Unreachable in practice (the endpoint already validated), but never persist a null host.
|
||||
@@ -242,10 +416,11 @@ public class PairingViewModel(
|
||||
error = PairingError.HttpOkButNotWebTerminal,
|
||||
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
|
||||
)
|
||||
return
|
||||
return false
|
||||
}
|
||||
hostStore.upsert(host)
|
||||
_uiState.value = PairingUiState.Paired(host)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun defaultName(endpoint: HostEndpoint): String =
|
||||
@@ -258,11 +433,13 @@ public class PairingViewModel(
|
||||
* `AppEnvironment.buildPairingProber()` over the ONE shared `OkHttpClient` (so the token cookie the
|
||||
* submit obtains is the same jar every later request and the WS upgrade read from).
|
||||
*
|
||||
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface. Their
|
||||
* defaults are deliberately the honest "this seam cannot reach the token gate" answers — `false` and
|
||||
* [TokenSubmission.Unavailable] — never a silent success.
|
||||
* The two token operations are DEFAULTED so a probe-only double still satisfies the interface — which
|
||||
* also leaves exactly one abstract member, so this stays a `fun interface` and a test/production
|
||||
* binding can be written as `PairingProber { endpoint -> … }`. Their defaults are deliberately the
|
||||
* honest "this seam cannot reach the token gate" answers — `false` and [TokenSubmission.Unavailable] —
|
||||
* never a silent success.
|
||||
*/
|
||||
public interface PairingProber {
|
||||
public fun interface PairingProber {
|
||||
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
|
||||
|
||||
/**
|
||||
@@ -295,6 +472,15 @@ public sealed interface TokenSubmission {
|
||||
/** 2xx (the server answers `204` to a non-HTML request) — `Set-Cookie: webterm_auth=…` was returned. */
|
||||
public data object Accepted : TokenSubmission
|
||||
|
||||
/**
|
||||
* **2xx with NO `Set-Cookie: webterm_auth=…`** — this host has `WEBTERM_TOKEN` unset, so there is
|
||||
* nothing to authenticate (FROZEN §1.1; the same discriminator as [AuthProbeResult.AuthDisabled] on
|
||||
* the typed-token path). The submitted token MUST NOT be persisted and this MUST NOT be read as
|
||||
* "authenticated": the host is simply open, and pairing continues exactly as for a zero-config LAN
|
||||
* deploy.
|
||||
*/
|
||||
public data object AuthDisabled : TokenSubmission
|
||||
|
||||
/** `401` — the token does not match the host's `WEBTERM_TOKEN`. */
|
||||
public data object Rejected : TokenSubmission
|
||||
|
||||
@@ -312,21 +498,35 @@ public sealed interface TokenSubmission {
|
||||
public data object Unavailable : TokenSubmission
|
||||
}
|
||||
|
||||
/** Why the access-token prompt is showing an error. Maps to inert, app-authored copy in [PairingCopy]. */
|
||||
public enum class TokenError {
|
||||
/** The submitted token was rejected by the host (`401`). */
|
||||
INVALID,
|
||||
|
||||
/** The host is rate-limiting auth attempts (`429`) — wait, do not hammer it. */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** The host did not answer the submit (or answered unintelligibly). */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The app cannot submit a token at all on this seam. */
|
||||
UNAVAILABLE,
|
||||
/**
|
||||
* 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
|
||||
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
|
||||
* `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,
|
||||
tokens: AccessTokenSource = AccessTokenSource.NONE,
|
||||
): PairingProber =
|
||||
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws, tokens) }
|
||||
|
||||
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -396,6 +596,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]. */
|
||||
@@ -447,9 +652,57 @@ public enum class EntryError {
|
||||
INVALID_URL,
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the access-token step failed. Rendered next to the token field on the confirm step (the typed-token
|
||||
* path) or under the dedicated [PairingUiState.TokenRequired] prompt (the discovered-gate path), 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/submitted token is wrong. */
|
||||
INVALID,
|
||||
|
||||
/**
|
||||
* `POST /auth` → **429**: the server's 10/min/IP limiter tripped. ONE case for both entry points —
|
||||
* the typed-token path and the prompt's submit path hit the same limiter on the same route, so two
|
||||
* enum constants with two different strings were the merge keeping both branches' names for one
|
||||
* server answer. Surfaced as-is; the app NEVER auto-retries.
|
||||
*/
|
||||
RATE_LIMITED,
|
||||
|
||||
/** 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 host did not answer the submit (or answered unintelligibly) — distinct from a wrong token. */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The app cannot submit a token at all on this seam (a probe-only prober). */
|
||||
UNAVAILABLE,
|
||||
|
||||
/** 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)
|
||||
|
||||
/**
|
||||
* What one pairing attempt wrote to the access-token store, and therefore what has to be put back if the
|
||||
* attempt does not end in [PairingUiState.Paired] (RULE 6). [previous] is what the store held for this
|
||||
* host beforehand — non-null only for a re-pair, whose working token a failed attempt must not cost.
|
||||
*/
|
||||
private data class TokenAttempt(val stored: Boolean, val previous: String?) {
|
||||
companion object {
|
||||
/** No token was written (none typed, or the host has no auth) — nothing to undo. */
|
||||
val NONE: TokenAttempt = TokenAttempt(stored = false, previous = null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun PairingUiState.candidate(): Candidate? = when (this) {
|
||||
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
|
||||
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
|
||||
@@ -462,6 +715,32 @@ 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.
|
||||
*
|
||||
* A wrong token, an unreachable host and a rate-limit read differently on purpose — collapsing them
|
||||
* would tell the user to re-type a token that was fine — and the rate-limit line tells them to WAIT,
|
||||
* because the app never retries by itself.
|
||||
*/
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.REQUIRED ->
|
||||
"该主机启用了访问令牌(WEBTERM_TOKEN)。请填写访问令牌后再连接。"
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上的 WEBTERM_TOKEN 后重试(区分大小写)。"
|
||||
TokenError.RATE_LIMITED ->
|
||||
"尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。"
|
||||
TokenError.MALFORMED ->
|
||||
"访问令牌格式不合法:需 16–512 个字符,且只能包含 A–Z a–z 0–9 . _ ~ + / = -。"
|
||||
TokenError.UNVERIFIABLE ->
|
||||
"无法验证访问令牌:服务器返回了意外响应。请确认地址和端口指向 web-terminal。"
|
||||
TokenError.UNREACHABLE ->
|
||||
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
|
||||
TokenError.UNAVAILABLE ->
|
||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||
TokenError.STORE_FAILED ->
|
||||
"无法在本机安全保存访问令牌(加密存储写入失败)。请重试。"
|
||||
}
|
||||
|
||||
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
|
||||
is PairingError.HostUnreachable ->
|
||||
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
|
||||
@@ -489,22 +768,8 @@ 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 ->
|
||||
"连接超时。主机可能不可达,或响应过慢。"
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for the access-token prompt. A wrong token and an unreachable host read differently on
|
||||
* purpose, and the rate-limit line tells the user to WAIT — the app never retries by itself.
|
||||
*/
|
||||
fun describeToken(error: TokenError): String = when (error) {
|
||||
TokenError.INVALID ->
|
||||
"访问令牌不正确。请核对主机上 WEBTERM_TOKEN 的值(区分大小写)后重试。"
|
||||
TokenError.RATE_LIMITED ->
|
||||
"尝试次数过多,主机已暂时限流(每分钟最多 10 次)。请等待约一分钟再试——App 不会自动重试。"
|
||||
TokenError.UNREACHABLE ->
|
||||
"提交访问令牌时主机没有正常响应。请检查网络与地址后重试。"
|
||||
TokenError.UNAVAILABLE ->
|
||||
"当前无法提交访问令牌(应用未连上该主机的网络通道)。请返回重试配对。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import wang.yaojia.webterm.api.models.UiConfig
|
||||
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.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
@@ -25,6 +27,7 @@ import wang.yaojia.webterm.tlsandroid.IdentityRepository
|
||||
import wang.yaojia.webterm.transport.AuthCookieJar
|
||||
import wang.yaojia.webterm.transport.AuthCookieSnapshot
|
||||
import wang.yaojia.webterm.transport.authCookieHostKey
|
||||
import wang.yaojia.webterm.viewmodels.AccessTokenProber
|
||||
import wang.yaojia.webterm.viewmodels.ApiClientQueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.HostRemovalGateway
|
||||
import wang.yaojia.webterm.viewmodels.PairingProber
|
||||
@@ -32,9 +35,12 @@ import wang.yaojia.webterm.viewmodels.PersistedUnreadWatermarkStore
|
||||
import wang.yaojia.webterm.viewmodels.QueueGateway
|
||||
import wang.yaojia.webterm.viewmodels.TokenSubmission
|
||||
import wang.yaojia.webterm.viewmodels.UnreadWatermarkStore
|
||||
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
|
||||
import wang.yaojia.webterm.wire.HttpResponse
|
||||
import wang.yaojia.webterm.wire.HttpTransport
|
||||
import wang.yaojia.webterm.wire.TermTransport
|
||||
import java.net.URI
|
||||
@@ -81,6 +87,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,
|
||||
@@ -147,12 +158,16 @@ public class AppEnvironment @Inject constructor(
|
||||
* The submit rides the SAME client as everything else, which is the whole point: the `webterm_auth`
|
||||
* cookie it earns lands in the shared [AuthCookieJar] and is therefore replayed on every later REST
|
||||
* call AND on the WS upgrade.
|
||||
*
|
||||
* The probe also authenticates from [accessTokenStore] (B5 / §1.1) — its two HTTP legs AND its WS
|
||||
* upgrade read the token the pairing flow may have just stored, so both halves of the gate work:
|
||||
* a token typed up-front (stored, then probed) and one submitted at the prompt (cookie jar).
|
||||
*/
|
||||
public fun buildPairingProber(): PairingProber = object : PairingProber {
|
||||
private val gateway = AccessTokenGateway { httpTransportLazy.get() }
|
||||
|
||||
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult =
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get())
|
||||
runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get(), accessTokenStore)
|
||||
|
||||
override suspend fun requiresAccessToken(endpoint: HostEndpoint): Boolean =
|
||||
gateway.requiresAccessToken(endpoint)
|
||||
@@ -161,6 +176,14 @@ public class AppEnvironment @Inject constructor(
|
||||
gateway.submit(endpoint, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 session-list / manage-page thumbnail pipeline for one host (§6.7). Per-host because its
|
||||
* preview fetch is bound to that host's [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
|
||||
@@ -225,6 +248,7 @@ public class AppEnvironment @Inject constructor(
|
||||
hostStore = hostStore,
|
||||
lastSessionStore = lastSessionStore,
|
||||
authCookieStore = authCookieStoreLazy.get(),
|
||||
accessTokenStore = accessTokenStore,
|
||||
watermarkStore = sessionWatermarkStore,
|
||||
pushUnregister = pushUnregister,
|
||||
currentPushToken = { pushTokenSource.currentToken() },
|
||||
@@ -390,9 +414,11 @@ public fun interface PushTokenSource {
|
||||
* pushing notifications to the device forever. It is **best-effort**: a host that is switched off
|
||||
* must still be removable, and the local state is what the user asked us to forget.
|
||||
* 2. **Then the local state**, in the order that keeps a failure retryable: the session watermarks, the
|
||||
* last-session pointer, the persisted + live `webterm_auth` cookie, and the paired record LAST. Any
|
||||
* throw propagates with the host still paired, so the user can simply try again — the UI treats that
|
||||
* as "nothing was removed" ([HostRemovalGateway]).
|
||||
* last-session pointer, the persisted + live `webterm_auth` cookie, **the Keystore-held access
|
||||
* token** ([AccessTokenStore] — the same credential's second durable home; the cookie's value IS
|
||||
* the token, `src/http/auth.ts`), and the paired record LAST. Any throw propagates with the host
|
||||
* still paired, so the user can simply try again — the UI treats that as "nothing was removed"
|
||||
* ([HostRemovalGateway]).
|
||||
*
|
||||
* ### What is deliberately NOT removed
|
||||
* - **The device certificate / AndroidKeyStore identity** — it is ONE app-wide mTLS identity shared by
|
||||
@@ -409,6 +435,14 @@ public class HostRemover(
|
||||
private val hostStore: HostStore,
|
||||
private val lastSessionStore: LastSessionStore,
|
||||
private val authCookieStore: AuthCookieStore,
|
||||
/**
|
||||
* The OTHER durable home of the same shell credential (B5). `authCookieStore` alone is not
|
||||
* "every trace": the token is what the cookie's value IS (`src/http/auth.ts` builds
|
||||
* `webterm_auth=<token>`), and [AccessTokenStore] is keyed by
|
||||
* [wang.yaojia.webterm.wire.HostEndpoint.originHeader] — NOT by the paired-host record — so a
|
||||
* retained token silently re-authenticates the moment the same URL is added again.
|
||||
*/
|
||||
private val accessTokenStore: AccessTokenStore,
|
||||
private val watermarkStore: SessionWatermarkStore,
|
||||
private val pushUnregister: HostPushUnregister,
|
||||
private val currentPushToken: suspend () -> String?,
|
||||
@@ -433,6 +467,9 @@ public class HostRemover(
|
||||
authCookieStore.removeHost(hostKey)
|
||||
clearLiveCookies(hostKey) // the in-memory jar too, or the credential rides the next dial
|
||||
}
|
||||
// The SAME credential's other durable home (B5). Keyed by origin, not by host id, so it
|
||||
// survives the record and silently re-authenticates a re-added URL unless dropped here.
|
||||
accessTokenStore.remove(host.endpoint)
|
||||
hostStore.remove(hostId)
|
||||
}
|
||||
|
||||
@@ -504,6 +541,11 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) {
|
||||
/**
|
||||
* `POST /auth` with [token] in an urlencoded form body.
|
||||
*
|
||||
* A 2xx is NOT on its own an acceptance: the presence of `Set-Cookie: webterm_auth=…` is the frozen
|
||||
* §1.1 discriminator between "the token is correct" ([TokenSubmission.Accepted]) and "this host has
|
||||
* no auth configured" ([TokenSubmission.AuthDisabled]) — and the caller persists a secret on the
|
||||
* first but must not on the second.
|
||||
*
|
||||
* [token] appears in the BODY only — never the URL, never a header, never the returned outcome, never
|
||||
* a log line — and nothing here retains it.
|
||||
*/
|
||||
@@ -526,18 +568,34 @@ public class AccessTokenGateway(private val http: () -> HttpTransport) {
|
||||
return TokenSubmission.Unreachable(reason = error::class.simpleName ?: UNKNOWN_ERROR)
|
||||
}
|
||||
return when {
|
||||
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES -> TokenSubmission.Accepted
|
||||
// 2xx splits in TWO (FROZEN §1.1): only a `Set-Cookie: webterm_auth=…` means the token was
|
||||
// ACCEPTED. A bare 204 means the host has `WEBTERM_TOKEN` unset — nothing to authenticate, so
|
||||
// the caller must persist nothing. Collapsing them puts a shell credential in the Keystore
|
||||
// for an open host and makes the client believe it is authenticated.
|
||||
response.status in HTTP_OK until HTTP_MULTIPLE_CHOICES ->
|
||||
if (response.carriesAuthCookie()) TokenSubmission.Accepted else TokenSubmission.AuthDisabled
|
||||
response.status == HTTP_UNAUTHORIZED -> TokenSubmission.Rejected
|
||||
response.status == HTTP_TOO_MANY_REQUESTS -> TokenSubmission.RateLimited
|
||||
else -> TokenSubmission.Unreachable(reason = "HTTP ${response.status}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the response set OUR auth cookie. Header names are case-insensitive on the wire, and the
|
||||
* value must name [AuthCookie.NAME] — a proxy's own `Set-Cookie` proves nothing about the gate. Same
|
||||
* rule as `:api-client`'s `AuthProbe.carriesAuthCookie`; never logs the value.
|
||||
*/
|
||||
private fun HttpResponse.carriesAuthCookie(): Boolean =
|
||||
headers.entries.any { (name, value) ->
|
||||
name.equals(SET_COOKIE_HEADER, ignoreCase = true) && value.startsWith("${AuthCookie.NAME}=")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIVE_SESSIONS_PATH = "/live-sessions"
|
||||
const val AUTH_PATH = "/auth"
|
||||
const val TOKEN_FIELD = "token"
|
||||
const val CONTENT_TYPE_HEADER = "Content-Type"
|
||||
const val SET_COOKIE_HEADER = "Set-Cookie"
|
||||
const val FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
|
||||
const val HTTP_OK = 200
|
||||
const val HTTP_MULTIPLE_CHOICES = 300
|
||||
|
||||
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,374 @@
|
||||
package wang.yaojia.webterm.viewmodels
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
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]);
|
||||
* - **no stranded secret** (F2): the flip side of storing before probing is that a pairing which does
|
||||
* not reach [PairingUiState.Paired] — failed probe, or abandoned mid-probe — must roll the store back
|
||||
* to exactly what it held before, restoring a re-paired host's previous token rather than clobbering it;
|
||||
* - 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"
|
||||
|
||||
/** The token an already-paired host holds, so a failed re-pair can be shown not to clobber it. */
|
||||
const val PREVIOUS_TOKEN = "0123456789abcdefOLD1"
|
||||
|
||||
/** A cert-gated native-tunnel host (`WarningTier.TUNNEL`). */
|
||||
const val TUNNEL = "https://star.terminal.yaojia.wang"
|
||||
}
|
||||
|
||||
/** 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 RATE_LIMITED`() = 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)
|
||||
// ONE constant for the server's ONE 429 — both entry points hit the same /auth limiter.
|
||||
assertEquals(TokenError.RATE_LIMITED, 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()))
|
||||
}
|
||||
|
||||
// ── a pairing that does not succeed must not strand the secret (F2) ─────────────────────────
|
||||
//
|
||||
// The token is stored BEFORE the probe on purpose (the probe's own HTTP+WS legs read it back out
|
||||
// of the store to authenticate). The flip side is that every path which does NOT end in `Paired`
|
||||
// has to put the store back the way it found it — otherwise a mistyped port leaves a live token
|
||||
// on disk for a host that was never paired, and nothing ever removes it.
|
||||
|
||||
@Test
|
||||
fun `a probe failure after the token was accepted leaves no stranded token`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
var tokenVisibleToProbe: String? = null
|
||||
val prober = PairingProber { endpoint ->
|
||||
tokenVisibleToProbe = tokens.tokenFor(endpoint)
|
||||
PairingProbeResult.Failure(PairingError.HostUnreachable("connect refused"))
|
||||
}
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertEquals(TOKEN, tokenVisibleToProbe, "the probe's legs must still authenticate from the store")
|
||||
assertInstanceOf(PairingUiState.Failed::class.java, vm.uiState.value)
|
||||
assertNull(tokens.tokenFor(endpoint()), "a host that never paired must not keep a stored token")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed re-pair restores the token the host was already paired with`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
tokens.put(endpoint(), PREVIOUS_TOKEN)
|
||||
val prober = FakeProber { PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) }
|
||||
val vm = newVm(prober, FakeAuthProber { AuthProbeResult.Accepted }, tokens)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertEquals(
|
||||
PREVIOUS_TOKEN,
|
||||
tokens.tokenFor(endpoint()),
|
||||
"a failed re-pair rolls back to the token that was already working, it does not clobber it",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one test that needs a REAL suspension point mid-probe, so it runs on the default
|
||||
* [kotlinx.coroutines.test.StandardTestDispatcher] rather than the unconfined dispatcher the rest of
|
||||
* the file uses. Note `runCurrent()`, not `advanceUntilIdle()`: the probe runs in `backgroundScope`,
|
||||
* and `advanceUntilIdle` stops as soon as no FOREGROUND work is left — it would never run the probe
|
||||
* at all (verified: the coroutine did not start).
|
||||
*/
|
||||
@Test
|
||||
fun `abandoning an in-flight probe rolls the freshly stored token back`() = runTest {
|
||||
val probeReached = CompletableDeferred<Unit>()
|
||||
val neverAnswers = CompletableDeferred<PairingProbeResult>()
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val vm = newVm(
|
||||
prober = { probeReached.complete(Unit); neverAnswers.await() },
|
||||
authProber = FakeAuthProber { AuthProbeResult.Accepted },
|
||||
tokenStore = tokens,
|
||||
)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(probeReached.isCompleted, "the probe must be in flight for this to test anything")
|
||||
assertEquals(TOKEN, tokens.tokenFor(endpoint()), "the in-flight probe reads the candidate token")
|
||||
|
||||
vm.reset() // the user backs out while the probe hangs
|
||||
runCurrent()
|
||||
|
||||
assertNull(tokens.tokenFor(endpoint()), "an abandoned pairing must not leave the secret behind")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cert-gated tunnel host is refused before the token is validated or stored`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val authProber = FakeAuthProber { AuthProbeResult.Accepted }
|
||||
val prober = FakeProber { PairingProbeResult.Success(it) }
|
||||
val vm = newVm(prober, authProber, tokens) // hasDeviceCert = false
|
||||
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(TUNNEL)
|
||||
vm.confirm(accessToken = TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
|
||||
assertTrue(authProber.calls.isEmpty(), "the cert-gate refuses BEFORE any network I/O")
|
||||
assertNull(tokens.tokenFor(endpoint(TUNNEL)), "nothing is stored for a host that was never probed")
|
||||
}
|
||||
|
||||
// ── 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" },
|
||||
)
|
||||
|
||||
|
||||
@@ -55,6 +55,15 @@ class AccessTokenGatewayTest {
|
||||
private fun status(code: Int): (HttpRequest) -> HttpResponse =
|
||||
{ HttpResponse(status = code, body = ByteArray(0)) }
|
||||
|
||||
/** 204 **with** the gate's `Set-Cookie: webterm_auth=…` — the "token accepted" answer. */
|
||||
private fun acceptedWithCookie(): (HttpRequest) -> HttpResponse = {
|
||||
HttpResponse(
|
||||
status = 204,
|
||||
body = ByteArray(0),
|
||||
headers = mapOf("Set-Cookie" to "webterm_auth=$TOKEN; Path=/; HttpOnly; SameSite=Strict"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun gateway(transport: HttpTransport) = AccessTokenGateway { transport }
|
||||
|
||||
// ── Gate detection: is this host token-gated, or is it just not a web-terminal? ──────────────────
|
||||
@@ -95,8 +104,37 @@ class AccessTokenGatewayTest {
|
||||
// ── POST /auth status mapping ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `204 accepts the token`() = runTest {
|
||||
assertEquals(TokenSubmission.Accepted, gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN))
|
||||
fun `204 with the auth cookie accepts the token`() = runTest {
|
||||
assertEquals(
|
||||
TokenSubmission.Accepted,
|
||||
gateway(RecordingTransport(acceptedWithCookie())).submit(endpoint(), TOKEN),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 · the FROZEN §1.1 discriminator, on the submit path too: **204 with no `Set-Cookie` means the
|
||||
* host has `WEBTERM_TOKEN` unset**, so there is nothing to authenticate. Before the merge this only
|
||||
* mattered to the jar; now `PairingViewModel.submitAccessToken` writes durable Keystore storage on
|
||||
* `Accepted`, so collapsing the two readings would persist a shell credential for a host that has no
|
||||
* auth at all — and let the client believe it is authenticated. Mirrors `AuthProbeResult.AuthDisabled`
|
||||
* on the proactive path (`AuthProbe.kt:19-24`).
|
||||
*/
|
||||
@Test
|
||||
fun `204 without Set-Cookie means the host has auth disabled, not an accepted token`() = runTest {
|
||||
assertEquals(
|
||||
TokenSubmission.AuthDisabled,
|
||||
gateway(RecordingTransport(status(204))).submit(endpoint(), TOKEN),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only OUR cookie counts as an acceptance`() = runTest {
|
||||
// A proxy's own Set-Cookie proves nothing about the WEBTERM_TOKEN gate.
|
||||
val foreign: (HttpRequest) -> HttpResponse = {
|
||||
HttpResponse(204, ByteArray(0), mapOf("set-cookie" to "proxy_session=abc123; Path=/"))
|
||||
}
|
||||
|
||||
assertEquals(TokenSubmission.AuthDisabled, gateway(RecordingTransport(foreign)).submit(endpoint(), TOKEN))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -10,9 +10,11 @@ import org.junit.jupiter.api.Test
|
||||
import wang.yaojia.webterm.hostregistry.AuthCookieRecord
|
||||
import wang.yaojia.webterm.hostregistry.Host
|
||||
import wang.yaojia.webterm.hostregistry.HostStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAccessTokenStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryAuthCookieStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
|
||||
import wang.yaojia.webterm.hostregistry.InMemorySessionWatermarkStore
|
||||
import wang.yaojia.webterm.wire.HostEndpoint
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -32,10 +34,15 @@ class HostRemoverTest {
|
||||
|
||||
private companion object {
|
||||
const val BASE = "http://laptop.local:3000"
|
||||
|
||||
/** A real shell credential shape (`AccessTokenRule`: 16–512 of `[A-Za-z0-9._~+/=-]`). */
|
||||
const val ACCESS_TOKEN = "0123456789abcdefTOKEN"
|
||||
val SESSION_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
|
||||
val SESSION_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
|
||||
|
||||
fun host(id: String = "h1"): Host = Host.create(id = id, name = "laptop", baseUrl = BASE)!!
|
||||
|
||||
fun endpoint(baseUrl: String = BASE): HostEndpoint = HostEndpoint.fromBaseUrl(baseUrl)!!
|
||||
}
|
||||
|
||||
private class RecordingHostStore(initial: List<Host>) : HostStore {
|
||||
@@ -71,7 +78,9 @@ class HostRemoverTest {
|
||||
): Triple<HostRemover, RecordingPush, Quad> {
|
||||
val lastSession = InMemoryLastSessionStore()
|
||||
val cookies = InMemoryAuthCookieStore()
|
||||
val tokens = InMemoryAccessTokenStore()
|
||||
val watermarks = InMemorySessionWatermarkStore()
|
||||
tokens.put(endpoint(), ACCESS_TOKEN)
|
||||
lastSession.setLastSessionId(SESSION_1.toString(), "h1")
|
||||
cookies.upsert(
|
||||
AuthCookieRecord(
|
||||
@@ -91,19 +100,21 @@ class HostRemoverTest {
|
||||
hostStore = hosts,
|
||||
lastSessionStore = lastSession,
|
||||
authCookieStore = cookies,
|
||||
accessTokenStore = tokens,
|
||||
watermarkStore = watermarks,
|
||||
pushUnregister = push,
|
||||
currentPushToken = { token },
|
||||
clearLiveCookies = { key -> cookieJarClears += key },
|
||||
log = { }, // android.util.Log is not mocked under JVM unit tests
|
||||
)
|
||||
return Triple(remover, push, Quad(hosts, lastSession, cookies, watermarks))
|
||||
return Triple(remover, push, Quad(hosts, lastSession, cookies, tokens, watermarks))
|
||||
}
|
||||
|
||||
private data class Quad(
|
||||
val hosts: RecordingHostStore,
|
||||
val lastSession: InMemoryLastSessionStore,
|
||||
val cookies: InMemoryAuthCookieStore,
|
||||
val tokens: InMemoryAccessTokenStore,
|
||||
val watermarks: InMemorySessionWatermarkStore,
|
||||
)
|
||||
|
||||
@@ -122,6 +133,41 @@ class HostRemoverTest {
|
||||
assertTrue(stores.watermarks.loadAll().isEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* F1 · the merge composed a SECOND durable home for the same shell credential
|
||||
* ([wang.yaojia.webterm.hostregistry.AccessTokenStore], Tink/AndroidKeystore) and left the un-pair
|
||||
* path pointed at only the first one. The token IS the cookie's value (`src/http/auth.ts` builds
|
||||
* `webterm_auth=<token>`), so keeping it is keeping a full-shell credential for a host the user
|
||||
* asked us to forget — and it is LIVE, because `AccessTokenSource.tokenFor` keys off the endpoint's
|
||||
* origin, not the paired-host record: re-adding the same URL with the token field left blank would
|
||||
* silently re-authenticate. Mirrors iOS `HostRemovalTests.removalLeavesNoTokenBehind`.
|
||||
*/
|
||||
@Test
|
||||
fun `un-pairing leaves no access token behind`() = runTest {
|
||||
val (remover, _, stores) = fixture()
|
||||
assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(endpoint())) // precondition: it really is there
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertNull(
|
||||
stores.tokens.tokenFor(endpoint()),
|
||||
"the access token is the cookie's own value — un-pairing must not keep a shell credential",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `un-pairing one host leaves another host's token alone`() = runTest {
|
||||
val other = Host.create(id = "h2", name = "other", baseUrl = "http://desk.local:3000")!!
|
||||
val hosts = RecordingHostStore(listOf(host(), other))
|
||||
val (remover, _, stores) = fixture(hosts = hosts)
|
||||
stores.tokens.put(other.endpoint, ACCESS_TOKEN)
|
||||
|
||||
remover.removeHost("h1", listOf(SESSION_1.toString()))
|
||||
|
||||
assertNull(stores.tokens.tokenFor(endpoint()))
|
||||
assertEquals(ACCESS_TOKEN, stores.tokens.tokenFor(other.endpoint), "token removal is host-scoped")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the remote DELETE runs while the host is still paired`() = runTest {
|
||||
val hosts = RecordingHostStore(listOf(host()))
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.junit.jupiter.api.DisplayName
|
||||
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.viewmodels.PairingProber
|
||||
import wang.yaojia.webterm.viewmodels.PairingUiState
|
||||
@@ -78,10 +79,16 @@ class PairingTokenFlowTest {
|
||||
prober: PairingProber,
|
||||
hasCert: () -> Boolean = { false },
|
||||
store: InMemoryHostStore = InMemoryHostStore(),
|
||||
tokenStore: InMemoryAccessTokenStore = InMemoryAccessTokenStore(),
|
||||
) = PairingViewModel(
|
||||
hostStore = store,
|
||||
prober = prober,
|
||||
hasDeviceCert = { hasCert() },
|
||||
// This file drives the DISCOVERED half of the gate (prompt → submit), so the token never comes
|
||||
// in through `confirm(accessToken = …)` and `POST /auth` is the prober's `submitAccessToken`,
|
||||
// never this seam. An accepted submit is still KEPT in the store, hence a real one here.
|
||||
tokenStore = tokenStore,
|
||||
authProber = { _, _ -> error("the typed-token POST /auth path is PairingTokenViewModelTest's") },
|
||||
newId = { "fixed-id" },
|
||||
)
|
||||
|
||||
@@ -158,6 +165,52 @@ class PairingTokenFlowTest {
|
||||
assertEquals(LAN, store.loadAll().single().endpoint.baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* F4 · the frozen §1.1 rule, on the reactive (prompt) path. A **204 without `Set-Cookie`** means the
|
||||
* host has no auth configured at all: the submitted token must NOT reach durable storage and the
|
||||
* client must NOT consider itself authenticated. Pairing still continues — an open host pairs exactly
|
||||
* as it did before the feature existed (same as `AuthProbeResult.AuthDisabled` on the typed path).
|
||||
*/
|
||||
@Test
|
||||
fun `a host with auth disabled pairs without ever persisting the submitted token`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { prober.gated = false; TokenSubmission.AuthDisabled }
|
||||
val tokenStore = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, tokenStore = tokenStore)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(
|
||||
null,
|
||||
tokenStore.tokenFor(endpoint(LAN)),
|
||||
"204 without Set-Cookie = auth disabled — a secret must never be persisted for it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accepted token IS persisted, so the WS upgrade can authenticate`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val prober = FakeProber(gated = true)
|
||||
prober.probeResult = gatedThenOpen(prober)
|
||||
prober.submission = { prober.gated = false; TokenSubmission.Accepted }
|
||||
val tokenStore = InMemoryAccessTokenStore()
|
||||
val vm = newVm(prober, tokenStore = tokenStore)
|
||||
vm.bind(backgroundScope)
|
||||
|
||||
vm.onManualEntry(LAN)
|
||||
vm.confirm()
|
||||
vm.submitAccessToken(TOKEN)
|
||||
|
||||
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
|
||||
assertEquals(TOKEN, tokenStore.tokenFor(endpoint(LAN)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a wrong token re-arms the prompt with an invalid-token error and does not re-probe`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
|
||||
Reference in New Issue
Block a user