Files
web-terminal/ios/App/WebTermTests/PairingTokenViewModelTests.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

431 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// C1 · Access-token UX in the pairing flow (ios-completion §1.1).
///
/// Every branch of the frozen contract is pinned here, because three of the four
/// `POST /auth` outcomes are NOT errors and must be told apart:
/// 204+Set-Cookie = save it · 204 without = this host has no auth (save nothing)
/// · 401 = wrong token · 429 = back off.
@MainActor
@Suite("Pairing access token")
struct PairingTokenViewModelTests {
// MARK: - Scripted probe (records what the VM asked for, in order)
private actor ProbeRecorder {
/// `(endpoint, token)` per host-verification call the token argument is
/// what proves the candidate reaches the probe.
private(set) var verifyCalls: [(origin: String, token: String?)] = []
private(set) var validateCalls: [String] = []
private var verifyResults: [Result<HostEndpoint, PairingError>]
private var validateResults:
[Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
init(
verifyResults: [Result<HostEndpoint, PairingError>] = [],
validateResults: [Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure>]
= []
) {
self.verifyResults = verifyResults
self.validateResults = validateResults
}
func verify(
_ endpoint: HostEndpoint, _ token: AccessToken?
) -> Result<HostEndpoint, PairingError> {
verifyCalls = verifyCalls + [(endpoint.originHeader, token?.rawValue)]
guard let next = verifyResults.first else { return .success(endpoint) }
verifyResults = Array(verifyResults.dropFirst())
return next
}
func validate(
_ token: AccessToken
) -> Result<AccessTokenProbeResult, PairingViewModel.TokenProbeFailure> {
validateCalls = validateCalls + [token.rawValue]
guard let next = validateResults.first else { return .success(.valid) }
validateResults = Array(validateResults.dropFirst())
return next
}
}
private static let base = "http://192.168.1.5:3000"
/// 32 chars from the frozen charset shape-valid, so any rejection in a test
/// comes from the server outcome, not from validation.
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
private func makeViewModel(
store: any HostStore,
recorder: ProbeRecorder
) -> PairingViewModel {
PairingViewModel(
store: store,
probe: PairingViewModel.Probe(
verifyHost: { endpoint, token in await recorder.verify(endpoint, token) },
validateToken: { _, token in await recorder.validate(token) }
)
)
}
/// Drive the VM to the token prompt the way the user gets there: the host
/// answers 401, which the probe reports as `originRejected` with the
/// both-remedies hint.
private func viewModelAtTokenPrompt(
store: any HostStore = InMemoryHostStore(),
recorder: ProbeRecorder
) async -> PairingViewModel {
let viewModel = makeViewModel(store: store, recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
viewModel.beginAccessTokenEntry()
// Loud, not silent: a recorder whose first verification does NOT fail
// with a 401 would leave the VM elsewhere and make every assertion below
// vacuous.
if case .awaitingToken = viewModel.phase {} else {
Issue.record("harness expected .awaitingToken, got \(viewModel.phase)")
}
return viewModel
}
// MARK: - 401 prompt
@Test("401 配对失败 → 提供「输入访问令牌」,进入令牌输入态")
func unauthorizedFailureOffersTokenPrompt() async throws {
// The hint text itself is the probe's (pinned in
// PairingProbeUnauthorizedTests against the REAL probe); here it stands
// in verbatim, because the VM must surface it unchanged.
let hint = "主机以 401 拒绝了这次配对……请输入访问令牌后重试;"
+ "或在主机上设置 ALLOWED_ORIGINS=\(Self.base) 后重启 web-terminal。"
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: hint))])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.action == .enterAccessToken)
// The ambiguity is spelled out: BOTH remedies, since the server answers
// 401 for a bad token and a rejected Origin alike.
#expect(failure.message.contains("访问令牌"))
#expect(failure.message.contains("ALLOWED_ORIGINS=\(Self.base)"))
viewModel.beginAccessTokenEntry()
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("非 401 失败不给令牌入口beginAccessTokenEntry 无副作用)")
func nonUnauthorizedFailureHasNoTokenEntry() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.timeout)])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
viewModel.beginAccessTokenEntry()
guard case .failed(_, let failure) = viewModel.phase else {
Issue.record("expected .failed, got \(viewModel.phase)")
return
}
#expect(failure.action == .retry)
}
// MARK: - Shape validation happens BEFORE any network I/O
@Test("令牌太短 → 本地就拒(不发 /auth话术只带长度不带令牌")
func tooShortTokenIsRejectedLocally() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken("short")
#expect(await recorder.validateCalls.isEmpty)
let rejection = try #require(viewModel.tokenRejection)
#expect(rejection.contains("\(5)"))
#expect(!rejection.contains("short")) // never echo the value
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected to stay in .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("令牌含非法字符 → 本地就拒,不发 /auth")
func invalidCharactersRejectedLocally() async throws {
let recorder = ProbeRecorder(verifyResults: [.failure(.originRejected(hint: "401"))])
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken("abcdefghijklmnop qrstuv")
#expect(await recorder.validateCalls.isEmpty)
#expect(viewModel.tokenRejection?.isEmpty == false)
}
// MARK: - The four §1.1 outcomes
@Test("204+Set-Cookievalid→ 带令牌重跑探针,主机连令牌一起入库")
func validTokenIsSavedWithTheHost() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))], // first, unauthenticated
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// The re-verification carried the candidate token (2nd verify call).
let verifyCalls = await recorder.verifyCalls
#expect(verifyCalls.count == 2)
#expect(verifyCalls.first?.token == nil)
#expect(verifyCalls.last?.token == Self.goodToken)
// and the persisted record has it (Keychain in production).
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.accessToken?.rawValue == Self.goodToken)
let stored = try await store.loadAll()
#expect(stored.count == 1)
#expect(stored.first?.accessToken?.rawValue == Self.goodToken)
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
}
@Test("204 但没有 Set-Cookie该主机未启用鉴权→ 绝不保存令牌,也不声称已认证")
func authDisabledNeverPersistsAToken() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.authDisabled)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// Still in the prompt, told the truth, and NOTHING was stored.
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
#expect(viewModel.tokenRejection == PairingCopy.tokenNotRequired)
#expect(try await store.loadAll().isEmpty)
// No second verification either re-probing unauthenticated would just
// reproduce the same failure (the cause is not the token).
#expect(await recorder.verifyCalls.count == 1)
}
@Test("authDisabled 之后即使配对成功也不带令牌入库(候选已被清掉)")
func authDisabledClearsTheCandidate() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.authDisabled)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
// Back to confirm, then a (now succeeding) retry of the whole flow.
viewModel.cancelAccessTokenEntry()
await viewModel.confirmConnect()
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(host.accessToken == nil)
#expect(await recorder.verifyCalls.last?.token == nil)
}
@Test("401令牌错→ 留在输入态给出「令牌不正确」,不入库")
func invalidTokenKeepsThePrompt() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.invalidToken)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
#expect(viewModel.tokenRejection == PairingCopy.tokenInvalid)
#expect(try await store.loadAll().isEmpty)
guard case .awaitingToken = viewModel.phase else {
Issue.record("expected .awaitingToken, got \(viewModel.phase)")
return
}
}
@Test("429 → 「稍后再试」话术,不入库")
func rateLimitedKeepsThePrompt() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.rateLimited)]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
#expect(viewModel.tokenRejection == PairingCopy.tokenRateLimited)
}
@Test("/auth 传输失败 → 网络话术(不吞错、不假装令牌错)")
func transportFailureIsSurfaced() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.failure(.unreachable("Connection refused"))]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
let rejection = try #require(viewModel.tokenRejection)
#expect(rejection.contains("Connection refused"))
#expect(rejection != PairingCopy.tokenInvalid)
}
// MARK: - Recovering an already-paired host
@Test("对同一 origin 再配对一次 = 原地更新(复用 id、不重复添加、补上令牌")
func repairingSameOriginUpdatesInPlace() async throws {
// Arrange: a host paired before the server enabled WEBTERM_TOKEN.
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
let existing = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
_ = try await store.upsert(existing)
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
// Act
await viewModel.submitAccessToken(Self.goodToken)
// Assert: ONE host, same id (every lastSessionId/watermark keyed by it
// survives), original name kept, token now present.
let hosts = try await store.loadAll()
#expect(hosts.count == 1)
#expect(hosts.first?.id == existing.id)
#expect(hosts.first?.name == "书房 Mac")
#expect(hosts.first?.accessToken?.rawValue == Self.goodToken)
}
@Test("原地更新不会因为「用户没改名字」而把主机名覆盖成 IP")
func repairingKeepsUserChosenName() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
_ = try await store.upsert(
HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
)
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.name == "书房 Mac")
}
@Test("重新配对时用户改了名字 → 采用新名字")
func repairingAdoptsEditedName() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
_ = try await store.upsert(
HostRegistry.Host(id: UUID(), name: "旧名字", endpoint: endpoint)
)
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
viewModel.hostName = "客厅 Mac"
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.name == "客厅 Mac")
}
@Test("重新配对(未输新令牌)不会丢掉已保存的令牌")
func repairingPreservesStoredToken() async throws {
let store = InMemoryHostStore()
let url = try #require(URL(string: Self.base))
let endpoint = try #require(HostEndpoint(baseURL: url))
let token = try AccessToken(validating: Self.goodToken)
_ = try await store.upsert(HostRegistry.Host(
id: UUID(), name: "书房 Mac", endpoint: endpoint, accessToken: token
))
let viewModel = makeViewModel(store: store, recorder: ProbeRecorder())
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect()
#expect(try await store.loadAll().first?.accessToken == token)
}
// MARK: - Secret hygiene
@Test("取消令牌输入 → 回到确认页,候选令牌被丢弃")
func cancellingTokenEntryDropsTheCandidate() async throws {
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(recorder: recorder)
viewModel.cancelAccessTokenEntry()
guard case .confirming = viewModel.phase else {
Issue.record("expected .confirming, got \(viewModel.phase)")
return
}
#expect(viewModel.tokenRejection == nil)
await viewModel.confirmConnect()
#expect(await recorder.verifyCalls.last?.token == nil)
}
@Test("换一个配对目标不会继承上一个目标的令牌")
func newTargetDoesNotInheritTheToken() async throws {
let recorder = ProbeRecorder(validateResults: [.success(.valid)])
let viewModel = makeViewModel(store: InMemoryHostStore(), recorder: recorder)
viewModel.submitManualURL(Self.base)
await viewModel.confirmConnect() // succeeds paired
// A fresh VM state is what the sheet gives on re-entry; here we assert the
// reset path itself.
viewModel.cancel()
viewModel.submitManualURL("http://192.168.1.9:3000")
await viewModel.confirmConnect()
#expect(await recorder.verifyCalls.last?.token == nil)
#expect(await recorder.verifyCalls.last?.origin == "http://192.168.1.9:3000")
}
@Test("令牌绝不出现在任何可观察状态里(阶段/话术/主机描述)")
func tokenNeverLeaksIntoObservableState() async throws {
let store = InMemoryHostStore()
let recorder = ProbeRecorder(
verifyResults: [.failure(.originRejected(hint: "401"))],
validateResults: [.success(.valid)]
)
let viewModel = await viewModelAtTokenPrompt(store: store, recorder: recorder)
await viewModel.submitAccessToken(Self.goodToken)
guard case .paired(let host) = viewModel.phase else {
Issue.record("expected .paired, got \(viewModel.phase)")
return
}
#expect(!String(describing: viewModel.phase).contains(Self.goodToken))
#expect(!host.description.contains(Self.goodToken))
#expect(host.description.contains("hasAccessToken: true")) // presence only
#expect(viewModel.tokenRejection == nil)
}
}