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.
200 lines
7.6 KiB
Swift
200 lines
7.6 KiB
Swift
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)
|
||
}
|
||
}
|