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.
This commit is contained in:
199
ios/App/WebTermTests/HostRemovalTests.swift
Normal file
199
ios/App/WebTermTests/HostRemovalTests.swift
Normal file
@@ -0,0 +1,199 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user