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.
177 lines
6.9 KiB
Swift
177 lines
6.9 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import TestSupport
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// C1 · The pairing probe against a token-gated host, driven through the REAL
|
||
/// `runPairingProbe` over `FakeHTTPTransport` + `FakeTransport`.
|
||
///
|
||
/// Two things are pinned:
|
||
/// 1. a 401 is no longer misreported as "Origin rejected" — with the token gate
|
||
/// live, 401 has TWO causes and one status code, so the copy must name both;
|
||
/// 2. the candidate token really travels: `Cookie: webterm_auth=<t>` on every
|
||
/// HTTP leg (RO GET and guarded DELETE alike, §1.1 — the cookie is orthogonal
|
||
/// to `Origin`, which only the DELETE carries).
|
||
///
|
||
/// These live in the App test target because APIClient's own test suite is B1's
|
||
/// file ownership; the probe function under test is the package's public one.
|
||
@Suite("Pairing probe · 401 and the token cookie")
|
||
struct PairingProbeUnauthorizedTests {
|
||
private static let base = "http://192.168.1.5:3000"
|
||
private static let token = "abcdefghijklmnopqrstuvwxyz012345"
|
||
private static let probeSessionId = "1b671a64-40d5-491e-99b0-da01ff1f3341"
|
||
|
||
private func endpoint() throws -> HostEndpoint {
|
||
let url = try #require(URL(string: Self.base))
|
||
return try #require(HostEndpoint(baseURL: url))
|
||
}
|
||
|
||
/// Script the whole happy path: RO list → WS attach → guarded kill.
|
||
private func scriptHappyPath(
|
||
http: FakeHTTPTransport, ws: FakeTransport
|
||
) async throws {
|
||
await http.queueSuccess(
|
||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||
body: Data("[]".utf8)
|
||
)
|
||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
|
||
await http.queueSuccess(
|
||
method: "DELETE",
|
||
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
|
||
status: 204
|
||
)
|
||
}
|
||
|
||
// MARK: - 401 → both remedies
|
||
|
||
@Test("RO 探针收到 401 → 不是「不是 web-terminal」,而是给出令牌+ALLOWED_ORIGINS 两条补救")
|
||
func readOnly401NamesBothRemedies() async throws {
|
||
let http = FakeHTTPTransport()
|
||
let ws = FakeTransport()
|
||
await http.queueSuccess(
|
||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||
status: 401,
|
||
body: Data(#"{"error":"unauthorized"}"#.utf8)
|
||
)
|
||
|
||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||
|
||
guard case .failure(.originRejected(let hint)) = result else {
|
||
Issue.record("expected originRejected(hint:), got \(result)")
|
||
return
|
||
}
|
||
#expect(hint.contains("401"))
|
||
#expect(hint.contains("访问令牌"))
|
||
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||
// The WS leg is never reached, so no PTY is spawned on a host that
|
||
// refuses us.
|
||
#expect(await ws.connectAttempts.isEmpty)
|
||
}
|
||
|
||
@Test("WS 升级被拒(无法归类)→ 同一条两因两解的话术")
|
||
func upgradeRejectionNamesBothRemedies() async throws {
|
||
let http = FakeHTTPTransport()
|
||
let ws = FakeTransport()
|
||
await http.queueSuccess(
|
||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||
body: Data("[]".utf8)
|
||
)
|
||
await ws.scriptConnectFailure()
|
||
|
||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||
|
||
guard case .failure(.originRejected(let hint)) = result else {
|
||
Issue.record("expected originRejected(hint:), got \(result)")
|
||
return
|
||
}
|
||
#expect(hint.contains("访问令牌"))
|
||
#expect(hint.contains("ALLOWED_ORIGINS=\(Self.base)"))
|
||
#expect(!hint.contains(":443"))
|
||
}
|
||
|
||
@Test("守卫 DELETE 收到 401(而非 403)→ 同样是两因两解,不报「主机不可达」")
|
||
func guardedDelete401NamesBothRemedies() async throws {
|
||
let http = FakeHTTPTransport()
|
||
let ws = FakeTransport()
|
||
await http.queueSuccess(
|
||
url: try #require(URL(string: "\(Self.base)/live-sessions")),
|
||
body: Data("[]".utf8)
|
||
)
|
||
await ws.emit(frame: #"{"type":"attached","sessionId":"\#(Self.probeSessionId)"}"#)
|
||
await http.queueSuccess(
|
||
method: "DELETE",
|
||
url: try #require(URL(string: "\(Self.base)/live-sessions/\(Self.probeSessionId)")),
|
||
status: 401
|
||
)
|
||
|
||
let result = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||
|
||
guard case .failure(.originRejected(let hint)) = result else {
|
||
Issue.record("expected originRejected(hint:), got \(result)")
|
||
return
|
||
}
|
||
#expect(hint.contains("访问令牌"))
|
||
}
|
||
|
||
// MARK: - The candidate token actually travels
|
||
|
||
@Test("带候选令牌的探针:两条 HTTP 腿都带 Cookie: webterm_auth,且只读腿仍不带 Origin")
|
||
func tokenTravelsOnBothHTTPLegs() async throws {
|
||
let http = FakeHTTPTransport()
|
||
let ws = FakeTransport()
|
||
try await scriptHappyPath(http: http, ws: ws)
|
||
|
||
let result = await runPairingProbe(
|
||
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
|
||
)
|
||
|
||
guard case .success = result else {
|
||
Issue.record("expected success, got \(result)")
|
||
return
|
||
}
|
||
let requests = await http.recordedRequests
|
||
#expect(requests.count == 2)
|
||
for request in requests {
|
||
#expect(
|
||
request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)",
|
||
"every probe leg must carry the token cookie"
|
||
)
|
||
}
|
||
// Origin-iff-G is untouched by the token (§1.1: orthogonal).
|
||
let readOnly = try #require(requests.first)
|
||
let guarded = try #require(requests.last)
|
||
#expect(readOnly.value(forHTTPHeaderField: "Origin") == nil)
|
||
#expect(guarded.value(forHTTPHeaderField: "Origin") == Self.base)
|
||
}
|
||
|
||
@Test("没有候选令牌 → 一个 Cookie 头都不加(LAN 零配置逐字不变)")
|
||
func noTokenMeansNoCookieHeader() async throws {
|
||
let http = FakeHTTPTransport()
|
||
let ws = FakeTransport()
|
||
try await scriptHappyPath(http: http, ws: ws)
|
||
|
||
_ = await runPairingProbe(endpoint: try endpoint(), http: http, ws: ws)
|
||
|
||
for request in await http.recordedRequests {
|
||
#expect(request.value(forHTTPHeaderField: "Cookie") == nil)
|
||
}
|
||
}
|
||
|
||
@Test("令牌不出现在 URL 里(绝不进查询串/日志)")
|
||
func tokenNeverAppearsInAURL() async throws {
|
||
let http = FakeHTTPTransport()
|
||
let ws = FakeTransport()
|
||
try await scriptHappyPath(http: http, ws: ws)
|
||
|
||
_ = await runPairingProbe(
|
||
endpoint: try endpoint(), http: http, ws: ws, accessToken: Self.token
|
||
)
|
||
|
||
for request in await http.recordedRequests {
|
||
#expect(request.url?.absoluteString.contains(Self.token) == false)
|
||
}
|
||
}
|
||
}
|