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.
431 lines
18 KiB
Swift
431 lines
18 KiB
Swift
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-Cookie(valid)→ 带令牌重跑探针,主机连令牌一起入库")
|
||
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)
|
||
}
|
||
}
|