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.
347 lines
15 KiB
Swift
347 lines
15 KiB
Swift
import Foundation
|
||
import HostRegistry
|
||
import SessionCore
|
||
import TestSupport
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// C1 · The app's single read path for per-host access tokens, plus the one
|
||
/// header-stamping point (`AccessTokenCookie`).
|
||
///
|
||
/// E1 · The WS cases are the important ones. SessionCore's `tokenProvider` is
|
||
/// `() -> String?` — no endpoint, no await — so C1 answered it with "the token
|
||
/// every paired host shares", which is *nil* for the deployment the token
|
||
/// exists for (a tokened tunnel host next to an open LAN host): the upgrade then
|
||
/// omitted the cookie, the server 401'd, and `.failed(.unauthorized)` is
|
||
/// terminal — with no in-app remedy, since re-entering the correct token left
|
||
/// the fleet just as mixed. The answer is now resolved PER HOST
|
||
/// (`wsToken(for:)`), exactly like the HTTP path and like Android's
|
||
/// `tokens.tokenFor(endpoint)`.
|
||
@Suite("Access-token source")
|
||
struct AccessTokenSourceTests {
|
||
private static let tokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||
private static let tokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||
|
||
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 makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
|
||
let url = try #require(URL(string: base))
|
||
return HostRegistry.Host(
|
||
id: UUID(),
|
||
name: base,
|
||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||
)
|
||
}
|
||
|
||
private func makeStore(_ hosts: [HostRegistry.Host]) async throws -> InMemoryHostStore {
|
||
let store = InMemoryHostStore()
|
||
for host in hosts {
|
||
_ = try await store.upsert(host)
|
||
}
|
||
return store
|
||
}
|
||
|
||
// MARK: - Per-origin resolution (the HTTP path)
|
||
|
||
@Test("按 origin 取到该主机的令牌;其它 origin 与无令牌主机都返回 nil")
|
||
func resolvesTokenByOrigin() async throws {
|
||
let store = try await makeStore([
|
||
try makeHost("http://192.168.1.5:3000", token: Self.tokenA),
|
||
try makeHost("http://192.168.1.9:3000", token: nil),
|
||
])
|
||
let source = AccessTokenSource(store: store)
|
||
|
||
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == Self.tokenA)
|
||
#expect(await source.token(forOrigin: "http://192.168.1.9:3000") == nil)
|
||
#expect(await source.token(forOrigin: "http://192.168.1.99:3000") == nil)
|
||
}
|
||
|
||
@Test("https 默认端口按 originHeader 规范化匹配(不写 :443)")
|
||
func matchesNormalizedHTTPSOrigin() async throws {
|
||
let store = try await makeStore([
|
||
try makeHost("https://mac.tailnet.ts.net", token: Self.tokenA),
|
||
])
|
||
let source = AccessTokenSource(store: store)
|
||
|
||
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net") == Self.tokenA)
|
||
#expect(await source.token(forOrigin: "https://mac.tailnet.ts.net:443") == nil)
|
||
}
|
||
|
||
@Test("存储读取失败 → nil(降级成「无令牌」,不让 App 崩或卡住)")
|
||
func storeFailureDegradesToNoToken() async throws {
|
||
let source = AccessTokenSource(store: ThrowingHostStore())
|
||
|
||
#expect(await source.token(forOrigin: "http://192.168.1.5:3000") == nil)
|
||
}
|
||
|
||
// MARK: - The per-host WS answer (E1 · replaces `hostIndependentToken`)
|
||
|
||
@Test("混合机群:有令牌的那台拿到自己的令牌,没令牌的那台拿 nil(旧解析对两台都给 nil)")
|
||
func resolvesPerHostTokenInAMixedFleet() async throws {
|
||
let tokened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||
let open = try makeHost("http://192.168.1.9:3000", token: nil)
|
||
let source = AccessTokenSource(store: try await makeStore([tokened, open]))
|
||
|
||
_ = await source.token(forOrigin: tokened.endpoint.originHeader) // 快照热身
|
||
|
||
#expect(source.wsToken(for: tokened) == Self.tokenA)
|
||
#expect(source.wsToken(for: open) == nil)
|
||
}
|
||
|
||
@Test("两台令牌不同 → 各拿各的,绝不把 A 的令牌发给 B")
|
||
func neverHandsOneHostsTokenToAnother() async throws {
|
||
let hostA = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||
let hostB = try makeHost("http://192.168.1.9:3000", token: Self.tokenB)
|
||
let source = AccessTokenSource(store: try await makeStore([hostA, hostB]))
|
||
|
||
_ = await source.token(forOrigin: hostA.endpoint.originHeader)
|
||
|
||
#expect(source.wsToken(for: hostA) == Self.tokenA)
|
||
#expect(source.wsToken(for: hostB) == Self.tokenB)
|
||
}
|
||
|
||
@Test("快照还没热(刚启动就开终端)→ 回落到该主机记录,绝不因竞态漏掉 Cookie")
|
||
func fallsBackToTheHostRecordBeforeAnyStoreRead() async throws {
|
||
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||
let source = AccessTokenSource(store: try await makeStore([host]))
|
||
|
||
// No `token(forOrigin:)` call yet — the snapshot is empty.
|
||
#expect(source.wsToken(for: host) == Self.tokenA)
|
||
}
|
||
|
||
@Test("令牌轮换后:下一次连接用存储里的新值,而不是打开终端时的旧值")
|
||
func picksUpARotatedTokenOnTheNextConnect() async throws {
|
||
let opened = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||
let store = try await makeStore([opened])
|
||
let source = AccessTokenSource(store: store)
|
||
let rotated = HostRegistry.Host(
|
||
id: opened.id, name: opened.name, endpoint: opened.endpoint,
|
||
accessToken: try AccessToken(validating: Self.tokenB)
|
||
)
|
||
_ = try await store.upsert(rotated)
|
||
|
||
_ = await source.token(forOrigin: opened.endpoint.originHeader) // 任一 HTTP 请求即刷新
|
||
|
||
#expect(source.wsToken(for: opened) == Self.tokenB)
|
||
}
|
||
|
||
@Test("存储读取失败 → 回落到主机记录(打开时的值),不是别的主机的令牌")
|
||
func wsTokenSurvivesAStoreFailure() async throws {
|
||
let host = try makeHost("http://192.168.1.5:3000", token: Self.tokenA)
|
||
let source = AccessTokenSource(store: ThrowingHostStore())
|
||
|
||
#expect(await source.token(forOrigin: host.endpoint.originHeader) == nil)
|
||
#expect(source.wsToken(for: host) == Self.tokenA)
|
||
}
|
||
|
||
@Test("完全没有令牌的机群 → nil(LAN 零配置逐字不变,不带 Cookie)")
|
||
func tokenlessFleetYieldsNoCookie() async throws {
|
||
let host = try makeHost("http://192.168.1.5:3000", token: nil)
|
||
let source = AccessTokenSource(store: try await makeStore([host]))
|
||
|
||
_ = await source.token(forOrigin: host.endpoint.originHeader)
|
||
|
||
#expect(source.wsToken(for: host) == nil)
|
||
}
|
||
}
|
||
|
||
/// E1 · The wiring that the per-host answer depends on: a terminal's WS
|
||
/// transport is built for THE host it dials, not once for the whole app.
|
||
@MainActor
|
||
@Suite("Per-host WS transport wiring")
|
||
struct PerHostTermTransportTests {
|
||
/// `@Sendable`-safe recorder for the hosts the factory was asked about.
|
||
private final class HostRecorder: @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private var hosts: [HostRegistry.Host] = []
|
||
|
||
func record(_ host: HostRegistry.Host) {
|
||
lock.withLock { hosts.append(host) }
|
||
}
|
||
|
||
var recorded: [HostRegistry.Host] { lock.withLock { hosts } }
|
||
}
|
||
|
||
private func makeHost(_ base: String, token: String?) throws -> HostRegistry.Host {
|
||
let url = try #require(URL(string: base))
|
||
return HostRegistry.Host(
|
||
id: UUID(), name: base,
|
||
endpoint: try #require(HostEndpoint(baseURL: url)),
|
||
accessToken: try token.map { try AccessToken(validating: $0) }
|
||
)
|
||
}
|
||
|
||
private func makeEnvironment(
|
||
shared: FakeTransport,
|
||
factory: (@Sendable (HostRegistry.Host) -> any TermTransport)? = nil
|
||
) throws -> AppEnvironment {
|
||
let defaults = try #require(UserDefaults(suiteName: "PerHostTermTransportTests"))
|
||
return AppEnvironment(
|
||
hostStore: InMemoryHostStore(),
|
||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||
http: FakeHTTPTransport(),
|
||
termTransport: shared,
|
||
probe: PairingViewModel.Probe(verifyHost: { _, _ in .failure(.timeout) }),
|
||
termTransportFactory: factory
|
||
)
|
||
}
|
||
|
||
@Test("未注入工厂 → 回落到共享传输(既有 App 层测试装配零回归)")
|
||
func fallsBackToTheSharedTransport() throws {
|
||
let shared = FakeTransport()
|
||
let environment = try makeEnvironment(shared: shared)
|
||
|
||
let resolved = environment.makeTermTransport(for: try makeHost("http://192.168.1.5:3000", token: nil))
|
||
|
||
#expect(resolved as? FakeTransport === shared)
|
||
}
|
||
|
||
@Test("注入工厂 → 每台主机各建一个传输,且工厂拿到的就是那台主机(含其令牌)")
|
||
func buildsOneTransportPerHost() throws {
|
||
let recorder = HostRecorder()
|
||
let environment = try makeEnvironment(
|
||
shared: FakeTransport(),
|
||
factory: { host in
|
||
recorder.record(host)
|
||
return FakeTransport()
|
||
}
|
||
)
|
||
let tokened = try makeHost("http://192.168.1.5:3000", token: String(repeating: "a", count: 32))
|
||
let open = try makeHost("http://192.168.1.9:3000", token: nil)
|
||
|
||
_ = environment.makeTermTransport(for: tokened)
|
||
_ = environment.makeTermTransport(for: open)
|
||
|
||
#expect(recorder.recorded.map(\.id) == [tokened.id, open.id])
|
||
#expect(recorder.recorded.first?.accessToken == tokened.accessToken)
|
||
}
|
||
|
||
@Test("TerminalSessionController 用自己那台主机建传输(终端 401 的根因就在这里)")
|
||
func controllerBuildsItsTransportForItsOwnHost() throws {
|
||
let recorder = HostRecorder()
|
||
let environment = try makeEnvironment(
|
||
shared: FakeTransport(),
|
||
factory: { host in
|
||
recorder.record(host)
|
||
return FakeTransport()
|
||
}
|
||
)
|
||
let host = try makeHost("http://192.168.1.5:3000", token: String(repeating: "b", count: 32))
|
||
|
||
_ = TerminalSessionController(
|
||
host: host, sessionId: nil, environment: environment,
|
||
onPendingChanged: { _, _ in }
|
||
)
|
||
|
||
#expect(recorder.recorded.map(\.id) == [host.id])
|
||
}
|
||
}
|
||
|
||
/// C1 · The App layer's one place where a token becomes a header.
|
||
@Suite("Access-token cookie stamping")
|
||
struct AccessTokenCookieTests {
|
||
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
|
||
|
||
private func request(_ url: String) throws -> URLRequest {
|
||
URLRequest(url: try #require(URL(string: url)))
|
||
}
|
||
|
||
@Test("请求 URL → originHeader 形式的 origin(路径/查询串被忽略)")
|
||
func derivesOriginFromRequestURL() throws {
|
||
let withPath = try request("http://192.168.1.5:3000/live-sessions/abc/preview?x=1")
|
||
|
||
#expect(AccessTokenCookie.origin(of: withPath) == "http://192.168.1.5:3000")
|
||
}
|
||
|
||
@Test("https 默认端口不写 :443(与服务器的 URL 规范化一致)")
|
||
func omitsDefaultHTTPSPort() throws {
|
||
let secure = try request("https://mac.tailnet.ts.net/live-sessions")
|
||
|
||
#expect(AccessTokenCookie.origin(of: secure) == "https://mac.tailnet.ts.net")
|
||
}
|
||
|
||
@Test("非 http(s) / 无 host 的请求 → nil(不可能是本 App 的主机)")
|
||
func rejectsNonHTTPRequests() throws {
|
||
#expect(AccessTokenCookie.origin(of: try request("ftp://192.168.1.5/x")) == nil)
|
||
#expect(AccessTokenCookie.origin(of: URLRequest(url: URL(fileURLWithPath: "/tmp"))) == nil)
|
||
}
|
||
|
||
@Test("盖上 Cookie: webterm_auth=<t>,且返回新请求(不原地改调用方的请求)")
|
||
func stampsCookieImmutably() throws {
|
||
let original = try request("http://192.168.1.5:3000/live-sessions")
|
||
|
||
let stamped = AccessTokenCookie.stamped(original, token: Self.token)
|
||
|
||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||
#expect(original.value(forHTTPHeaderField: "Cookie") == nil)
|
||
}
|
||
|
||
@Test("已经带 Cookie 的请求原样通过(配对探针的候选令牌不能被覆盖)")
|
||
func neverOverwritesAnExistingCookie() throws {
|
||
var candidate = try request("http://192.168.1.5:3000/live-sessions")
|
||
candidate.setValue("webterm_auth=candidate-token-value", forHTTPHeaderField: "Cookie")
|
||
|
||
let stamped = AccessTokenCookie.stamped(candidate, token: Self.token)
|
||
|
||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate-token-value")
|
||
}
|
||
|
||
@Test("Cookie 名与服务器 AUTH_COOKIE_NAME 逐字一致")
|
||
func cookieNameMatchesTheServer() {
|
||
#expect(AccessTokenCookie.name == "webterm_auth")
|
||
}
|
||
|
||
// MARK: - The transport choke point itself (no network involved)
|
||
|
||
@Test("传输层给每个请求按 origin 盖上令牌 Cookie(RO GET 也一样)")
|
||
func transportStampsEveryRequest() async throws {
|
||
let transport = URLSessionHTTPTransport(
|
||
identityProvider: { nil },
|
||
tokenForOrigin: { origin in
|
||
origin == "http://192.168.1.5:3000" ? Self.token : nil
|
||
}
|
||
)
|
||
let readOnly = try request("http://192.168.1.5:3000/live-sessions")
|
||
|
||
let stamped = await transport.authenticated(readOnly)
|
||
|
||
#expect(stamped.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)")
|
||
}
|
||
|
||
@Test("没有该 origin 的令牌 → 请求逐字不变(LAN 零配置主机不受影响)")
|
||
func transportLeavesTokenlessOriginsAlone() async throws {
|
||
let transport = URLSessionHTTPTransport(
|
||
identityProvider: { nil }, tokenForOrigin: { _ in nil }
|
||
)
|
||
let plain = try request("http://192.168.1.9:3000/live-sessions")
|
||
|
||
let result = await transport.authenticated(plain)
|
||
|
||
#expect(result.value(forHTTPHeaderField: "Cookie") == nil)
|
||
#expect(result.allHTTPHeaderFields == plain.allHTTPHeaderFields)
|
||
}
|
||
|
||
@Test("请求已带 Cookie(配对探针的候选令牌)→ 传输层不覆盖")
|
||
func transportNeverOverwritesTheProbeCandidate() async throws {
|
||
let transport = URLSessionHTTPTransport(
|
||
identityProvider: { nil }, tokenForOrigin: { _ in Self.token }
|
||
)
|
||
var candidate = try request("http://192.168.1.5:3000/live-sessions")
|
||
candidate.setValue("webterm_auth=candidate", forHTTPHeaderField: "Cookie")
|
||
|
||
let result = await transport.authenticated(candidate)
|
||
|
||
#expect(result.value(forHTTPHeaderField: "Cookie") == "webterm_auth=candidate")
|
||
}
|
||
}
|