Files
web-terminal/ios/App/WebTermTests/DeepLinkRouterTests.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

355 lines
14 KiB
Swift
Raw Permalink 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 Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
/// .ignoreWEBTERM_GATE push
/// payload DeepLinkHandler stash / host
/// /
@MainActor
@Suite("DeepLinkRouter")
struct DeepLinkRouterTests {
// MARK: - Fixtures
/// v4 UUIDversion nibble=4variant=8 Validation.isValidSessionId
private static let validHostId = "11111111-2222-4333-8444-555555555555"
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// version nibble=1Foundation UUID(uuidString:)
/// SESSION_ID_REv4 " Validation"
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
/// variant nibble=c v4 8/9/a/b
private static let badVariantId = "11111111-2222-4333-c444-555555555555"
private static func url(_ string: String) throws -> URL {
try #require(URL(string: string))
}
private static func openURL(
host: String = validHostId, join: String = validSessionId
) throws -> URL {
try url("webterminal://open?host=\(host)&join=\(join)")
}
private static func makeHost(id: UUID) throws -> HostRegistry.Host {
let base = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: base))
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
}
// MARK: - URL
@Test("合法 open 链接 → .openSession(hostId, sessionId)")
func validLinkRoutes() throws {
let route = DeepLinkRouter.route(url: try Self.openURL())
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
@Test("大写 UUID → 接受Validation 大小写不敏感)")
func uppercaseUUIDsAccepted() throws {
let route = DeepLinkRouter.route(
url: try Self.openURL(
host: Self.validHostId.uppercased(),
join: Self.validSessionId.uppercased()
)
)
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
@Test("未知多余 query 键 → 忽略键本身,链接仍路由")
func unknownExtraKeysIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(Self.validHostId)&join=\(Self.validSessionId)&utm=x&foo=bar"
)
)
let hostId = try #require(UUID(uuidString: Self.validHostId))
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
}
// MARK: - URL .ignore
/// T-iOS-35 ****"scheme webterminal .ignore"
/// http(s) web QR http(s)
/// `<origin>/?join=<uuid>` `DeepLinkJoinTests`****
/// URL `.ignore` `host=` query
/// web `join` "scheme "
/// scheme http/https/webterminal
@Test("https 但不是 web 分享形状(多余 query 键)→ .ignore")
func httpsWithExtraQueryKeysIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("白名单外的 schemeftp/file/javascript→ .ignore")
func nonWhitelistedSchemeIgnored() throws {
for scheme in ["ftp", "file", "javascript"] {
let route = DeepLinkRouter.route(
url: try Self.url("\(scheme)://open?join=\(Self.validSessionId)")
)
#expect(route == .ignore, "\(scheme) 不该被接受")
}
}
@Test("action 非 open → .ignore")
func wrongActionIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://kill?host=\(Self.validHostId)&join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("带非空 path → .ignore白名单外的形状")
func extraPathIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open/extra?host=\(Self.validHostId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("缺 host 键 → .ignore")
func missingHostKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://open?join=\(Self.validSessionId)")
)
#expect(route == .ignore)
}
@Test("缺 join 键 → .ignore")
func missingJoinKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url("webterminal://open?host=\(Self.validHostId)")
)
#expect(route == .ignore)
}
@Test("重复 host 键 → .ignore歧义输入绝不部分应用")
func duplicateHostKeyIgnored() throws {
let route = DeepLinkRouter.route(
url: try Self.url(
"webterminal://open?host=\(Self.validHostId)&host=\(Self.validHostId)&join=\(Self.validSessionId)"
)
)
#expect(route == .ignore)
}
@Test("UUID 非 v4version nibble=1→ .ignore钉住复用 Validation")
func nonV4UUIDIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.openURL(host: Self.v1StyleId)) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.v1StyleId)) == .ignore)
}
@Test("variant nibble 非 8/9/a/b → .ignore")
func badVariantUUIDIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.badVariantId)) == .ignore)
}
@Test("空值 / 垃圾值 / 空 query → .ignore不 crash")
func fuzzedValuesIgnored() throws {
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open?host=&join=")) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open")) == .ignore)
#expect(DeepLinkRouter.route(url: try Self.openURL(host: "not-a-uuid")) == .ignore)
#expect(
DeepLinkRouter.route(url: try Self.openURL(join: "'; DROP TABLE sessions;--")) == .ignore
)
}
// MARK: - Push payloadWEBTERM_GATET-iOS-21
@Test("payload 含合法 sessionId多余键忽略→ .gateSession")
func gatePayloadRoutes() throws {
let payload: [AnyHashable: Any] = [
"sessionId": Self.validSessionId,
"cls": "gate",
"token": "opaque",
"aps": ["category": "WEBTERM_GATE"],
]
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
#expect(DeepLinkRouter.route(from: payload) == .gateSession(sessionId: sessionId))
}
@Test("payload 缺 sessionId / 非字符串 / 非 v4 → .ignore")
func gatePayloadRejectsInvalid() {
#expect(DeepLinkRouter.route(from: [:]) == .ignore)
#expect(DeepLinkRouter.route(from: ["sessionId": 42]) == .ignore)
#expect(DeepLinkRouter.route(from: ["sessionId": Self.v1StyleId]) == .ignore)
}
}
/// DeepLinkHandler stash host
@MainActor
@Suite("DeepLinkHandler")
struct DeepLinkHandlerTests {
private enum StubError: Error { case storeFailure }
/// handler AppCoordinator
@MainActor
private final class ActionRecorder {
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
private(set) var pairingShownCount = 0
var actions: DeepLinkHandler.Actions {
DeepLinkHandler.Actions(
openSession: { [weak self] host, sessionId in
self?.opened.append((host, sessionId))
},
showPairing: { [weak self] in
self?.pairingShownCount += 1
}
)
}
}
private static func makeHost(id: UUID = UUID()) throws -> HostRegistry.Host {
let base = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: base))
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
}
private static func openURL(hostId: UUID, sessionId: UUID) throws -> URL {
try #require(URL(
string: "webterminal://open?host=\(hostId.uuidString)&join=\(sessionId.uuidString)"
))
}
/// UUID() v4 uuidString
private static func sessionId() -> UUID { UUID() }
// MARK: - app
@Test("热路径:已知 host → openSession(host, sessionId),无 hint")
func warmKnownHostOpens() async throws {
let host = try Self.makeHost()
let sessionId = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.host == host)
#expect(recorder.opened.first?.sessionId == sessionId)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == nil)
}
@Test("未知 host idUUID 合法但不在 store→ showPairing + 提示文案")
func unknownHostRoutesToPairing() async throws {
let stored = try Self.makeHost()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [stored] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 1)
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
}
@Test("host store 读失败 → 显式错误文案,不 showPairing、不 crash")
func storeFailureSurfacesExplicitCopy() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(
loadHosts: { throw StubError.storeFailure },
actions: recorder.actions
)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == DeepLinkCopy.hostLoadFailed)
}
// MARK: - + stash
@Test("非法链接 → ignoredCount+1无任何动作也不入 stash")
func invalidLinkCountedNeverStashed() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.handle(url: try #require(URL(string: "https://evil.example/?host=x")))
#expect(handler.ignoredCount == 1)
await handler.markReady() // ready stash
#expect(recorder.opened.isEmpty)
#expect(recorder.pairingShownCount == 0)
#expect(handler.hintMessage == nil)
}
// MARK: - stash
@Test("冷启动ready 前 handle → 挂起markReady → 恰好应用一次")
func coldLaunchStashAppliesOnReady() async throws {
let host = try Self.makeHost()
let sessionId = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
#expect(recorder.opened.isEmpty) //
await handler.markReady()
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.sessionId == sessionId)
await handler.markReady() // stash
#expect(recorder.opened.count == 1)
}
@Test("ready 前连续两条链接 → 只应用最新一条(单槽 stash")
func stashKeepsOnlyLatestLink() async throws {
let host = try Self.makeHost()
let first = Self.sessionId()
let second = Self.sessionId()
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: first))
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: second))
await handler.markReady()
#expect(recorder.opened.count == 1)
#expect(recorder.opened.first?.sessionId == second)
}
// MARK: - hint
@Test("clearHint → hintMessage 归位 nil")
func clearHintResets() async throws {
let recorder = ActionRecorder()
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
await handler.markReady()
await handler.handle(
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
)
#expect(handler.hintMessage != nil)
handler.clearHint()
#expect(handler.hintMessage == nil)
}
}