Files
web-terminal/ios/App/WebTermTests/HostRemovalTests.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

200 lines
7.6 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 · the path `PushRegistrar.handleHostRemoved` never had.
///
/// Two properties matter beyond "the row disappears":
/// 1. the APNs de-registration actually happens, and happens BEFORE the record
/// is deleted (the request needs the host's endpoint AND its access token,
/// which live in that record);
/// 2. no secret survives: the token is a field of the removed record, so the
/// same write that drops the host drops the token.
@MainActor
@Suite("Host removal")
struct HostRemovalTests {
private static let goodToken = "abcdefghijklmnopqrstuvwxyz012345"
/// Records the interleaving of the push hook and the store write.
private actor Journal {
enum Entry: Equatable {
case unregistered(UUID)
case removed(UUID)
}
private(set) var entries: [Entry] = []
func record(_ entry: Entry) {
entries = entries + [entry]
}
}
/// `InMemoryHostStore` + journalling of `remove`, so ordering is observable.
private actor JournallingStore: HostStore {
private let base = InMemoryHostStore()
private let journal: Journal
private let removeFails: Bool
init(journal: Journal, removeFails: Bool = false) {
self.journal = journal
self.removeFails = removeFails
}
func loadAll() async throws -> [HostRegistry.Host] { try await base.loadAll() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
try await base.upsert(host)
}
func remove(id: UUID) async throws -> [HostRegistry.Host] {
await journal.record(.removed(id))
if removeFails { throw StoreFailure() }
return try await base.remove(id: id)
}
func accessToken(host id: UUID) async throws -> AccessToken? {
try await base.accessToken(host: id)
}
func setAccessToken(
_ token: AccessToken?, host id: UUID
) async throws -> [HostRegistry.Host] {
try await base.setAccessToken(token, host: id)
}
}
private struct StoreFailure: Error {}
private actor ThrowingHostStore: HostStore {
func loadAll() async throws -> [HostRegistry.Host] { throw StoreFailure() }
func upsert(_ host: HostRegistry.Host) async throws -> [HostRegistry.Host] {
throw StoreFailure()
}
func remove(id: UUID) async throws -> [HostRegistry.Host] { throw StoreFailure() }
}
private func makeViewModel(store: any HostStore, journal: Journal) -> PairingViewModel {
PairingViewModel(
store: store,
probe: PairingViewModel.Probe(
verifyHost: { endpoint, _ in .success(endpoint) },
unregisterPush: { host in await journal.record(.unregistered(host.id)) }
)
)
}
private func makeHost(
name: String, port: Int, token: String? = nil
) throws -> HostRegistry.Host {
let url = try #require(URL(string: "http://192.168.1.5:\(port)"))
return HostRegistry.Host(
id: UUID(),
name: name,
endpoint: try #require(HostEndpoint(baseURL: url)),
accessToken: try token.map { try AccessToken(validating: $0) }
)
}
// MARK: - The wiring
@Test("移除主机:先注销该主机上的推送 token再删记录顺序是硬要求")
func removalDeregistersPushBeforeDeletingTheRecord() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(await journal.entries == [.unregistered(host.id), .removed(host.id)])
#expect(viewModel.pairedHosts.isEmpty)
#expect(try await store.loadAll().isEmpty)
}
@Test("移除主机后本机不留令牌(令牌是记录的一个字段,同一次写入一起消失)")
func removalLeavesNoTokenBehind() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000, token: Self.goodToken)
_ = try await store.upsert(host)
#expect(try await store.accessToken(host: host.id)?.rawValue == Self.goodToken)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(try await store.accessToken(host: host.id) == nil)
#expect(try await store.loadAll().allSatisfy { $0.accessToken == nil })
}
@Test("只移除指定主机,其它主机及其令牌不受影响")
func removalIsScopedToOneHost() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let doomed = try makeHost(name: "旧 Mac", port: 3000, token: Self.goodToken)
let kept = try makeHost(name: "新 Mac", port: 3100, token: Self.goodToken)
_ = try await store.upsert(doomed)
_ = try await store.upsert(kept)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: doomed.id)
#expect(viewModel.pairedHosts.map(\.id) == [kept.id])
#expect(try await store.accessToken(host: kept.id)?.rawValue == Self.goodToken)
#expect(await journal.entries == [.unregistered(doomed.id), .removed(doomed.id)])
}
@Test("未知 id → 完全 no-op不注销、不写存储")
func unknownIdIsANoOp() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal)
let host = try makeHost(name: "书房 Mac", port: 3000)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: UUID())
#expect(await journal.entries.isEmpty)
#expect(try await store.loadAll().count == 1)
}
@Test("存储写入失败 → 显式报错(不假装移除成功)")
func storeFailureIsSurfaced() async throws {
let journal = Journal()
let store = JournallingStore(journal: journal, removeFails: true)
let host = try makeHost(name: "书房 Mac", port: 3000)
_ = try await store.upsert(host)
let viewModel = makeViewModel(store: store, journal: journal)
await viewModel.loadPairedHosts()
await viewModel.removeHost(id: host.id)
#expect(viewModel.hostsErrorMessage == PairingCopy.hostRemoveFailed)
#expect(viewModel.pairedHosts.map(\.id) == [host.id]) // list unchanged
}
// MARK: - Manage-section loading
@Test("加载已配对主机:成功清错,失败给出显式话术")
func loadingPairedHostsSurfacesErrors() async throws {
let journal = Journal()
let failing = makeViewModel(store: ThrowingHostStore(), journal: journal)
await failing.loadPairedHosts()
#expect(failing.pairedHosts.isEmpty)
#expect(failing.hostsErrorMessage == PairingCopy.hostsLoadFailed)
let working = makeViewModel(store: InMemoryHostStore(), journal: journal)
await working.loadPairedHosts()
#expect(working.hostsErrorMessage == nil)
}
}