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:
176
ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift
Normal file
176
ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user