Files
web-terminal/ios/App/WebTermTests/PairingProbeUnauthorizedTests.swift
Yaojia Wang 284cfd193a 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.
2026-07-30 15:58:01 +02:00

177 lines
6.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}