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.
303 lines
12 KiB
Swift
303 lines
12 KiB
Swift
import Foundation
|
||
import HostRegistry
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-35 · web 分享 QR(`?join=`)互通(doc §4 A–C 组,条目 30–50)。
|
||
///
|
||
/// web 侧的分享 URL 形状是 `${location.origin}/?join=${sessionId}`
|
||
/// (`public/share.ts:55`)。手机拿到这个 URL 时它是**不可信外部输入**(二维码
|
||
/// 里可以是任何东西),所以 http(s) 分支比自定义 scheme 分支**更严**:
|
||
/// scheme/host/path/query 键逐项白名单、query 精确只许一个 `join`、拒 fragment、
|
||
/// 拒 userinfo,`sessionId` 仍只经冻结的 `Validation.isValidSessionId`。
|
||
///
|
||
/// 主机身份**只**经 `HostStore` 解析:origin 比对用 `HostEndpoint.originHeader`
|
||
/// (冻结的唯一 origin 派生点),未配对 → 落配对流程,**绝不**照链接内容静默新增主机。
|
||
@MainActor
|
||
@Suite("DeepLinkJoin")
|
||
struct DeepLinkJoinTests {
|
||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
|
||
|
||
private static func url(_ string: String) throws -> URL {
|
||
try #require(URL(string: string))
|
||
}
|
||
|
||
// MARK: - A. 接受 web 分享形状
|
||
|
||
@Test("http://<ip>:3000/?join=<v4> → .joinShared(origin, sessionId)")
|
||
func lanShareLinkRoutes() throws {
|
||
let route = DeepLinkRouter.route(
|
||
url: try Self.url("http://192.168.1.5:3000/?join=\(Self.validSessionId)")
|
||
)
|
||
|
||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||
#expect(route == .joinShared(origin: "http://192.168.1.5:3000", sessionId: sessionId))
|
||
}
|
||
|
||
@Test("https 默认端口不出现在 origin 里")
|
||
func httpsDefaultPortOmitted() throws {
|
||
let route = DeepLinkRouter.route(
|
||
url: try Self.url("https://mac.tailnet.ts.net/?join=\(Self.validSessionId)")
|
||
)
|
||
|
||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||
#expect(route == .joinShared(origin: "https://mac.tailnet.ts.net", sessionId: sessionId))
|
||
}
|
||
|
||
@Test("大写 scheme/host → origin 归一化为小写")
|
||
func originIsNormalizedLowercase() throws {
|
||
let route = DeepLinkRouter.route(
|
||
url: try Self.url("HTTP://MAC.LOCAL:3000/?join=\(Self.validSessionId)")
|
||
)
|
||
|
||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||
#expect(route == .joinShared(origin: "http://mac.local:3000", sessionId: sessionId))
|
||
}
|
||
|
||
@Test("path 为空或 \"/\" 都接受(origin + \"/?join=\" 产出 /)")
|
||
func emptyAndRootPathsAccepted() throws {
|
||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||
let expected = DeepLinkRouter.Route.joinShared(
|
||
origin: "http://mac.local:3000", sessionId: sessionId
|
||
)
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url("http://mac.local:3000/?join=\(Self.validSessionId)")
|
||
) == expected
|
||
)
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url("http://mac.local:3000?join=\(Self.validSessionId)")
|
||
) == expected
|
||
)
|
||
}
|
||
|
||
// MARK: - B. 白名单纪律
|
||
|
||
@Test("join 值非 v4 → .ignore(复用 Validation,不再造正则)")
|
||
func nonV4JoinIgnored() throws {
|
||
#expect(
|
||
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=\(Self.v1StyleId)"))
|
||
== .ignore
|
||
)
|
||
#expect(
|
||
DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=not-a-uuid")) == .ignore
|
||
)
|
||
#expect(DeepLinkRouter.route(url: try Self.url("http://mac.local/?join=")) == .ignore)
|
||
}
|
||
|
||
@Test("重复 join 键 → .ignore(歧义输入绝不部分应用)")
|
||
func duplicateJoinIgnored() throws {
|
||
let route = DeepLinkRouter.route(
|
||
url: try Self.url(
|
||
"http://mac.local/?join=\(Self.validSessionId)&join=\(Self.validSessionId)"
|
||
)
|
||
)
|
||
#expect(route == .ignore)
|
||
}
|
||
|
||
@Test("多余 query 键 → .ignore(web 形状精确只有一个 join)")
|
||
func extraQueryKeysIgnored() throws {
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)&x=1")
|
||
) == .ignore
|
||
)
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url("http://mac.local/?utm=a&join=\(Self.validSessionId)")
|
||
) == .ignore
|
||
)
|
||
}
|
||
|
||
@Test("非空 path → .ignore")
|
||
func nonEmptyPathIgnored() throws {
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url("http://mac.local/manage.html?join=\(Self.validSessionId)")
|
||
) == .ignore
|
||
)
|
||
}
|
||
|
||
@Test("带 fragment → .ignore")
|
||
func fragmentIgnored() throws {
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url("http://mac.local/?join=\(Self.validSessionId)#x")
|
||
) == .ignore
|
||
)
|
||
}
|
||
|
||
@Test("带 userinfo(二维码钓鱼形状)→ .ignore")
|
||
func userInfoIgnored() throws {
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url("http://user:pass@mac.local/?join=\(Self.validSessionId)")
|
||
) == .ignore
|
||
)
|
||
}
|
||
|
||
@Test("无 host → .ignore")
|
||
func missingHostIgnored() throws {
|
||
#expect(
|
||
DeepLinkRouter.route(url: try Self.url("http:///?join=\(Self.validSessionId)"))
|
||
== .ignore
|
||
)
|
||
}
|
||
|
||
@Test("自定义 scheme 分支零回归:webterminal 仍需 host+join 双 v4")
|
||
func customSchemeBranchUnchanged() throws {
|
||
#expect(
|
||
DeepLinkRouter.route(url: try Self.url("webterminal://open?join=\(Self.validSessionId)"))
|
||
== .ignore
|
||
)
|
||
let hostId = UUID()
|
||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||
#expect(
|
||
DeepLinkRouter.route(
|
||
url: try Self.url(
|
||
"webterminal://open?host=\(hostId.uuidString)&join=\(Self.validSessionId)"
|
||
)
|
||
) == .openSession(hostId: hostId, sessionId: sessionId)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// C 组 —— `DeepLinkHandler` 侧:origin → 已配对主机的解析。
|
||
@MainActor
|
||
@Suite("DeepLinkJoinHandler")
|
||
struct DeepLinkJoinHandlerTests {
|
||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||
|
||
@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(_ base: String) throws -> HostRegistry.Host {
|
||
let url = try #require(URL(string: base))
|
||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||
return HostRegistry.Host(id: UUID(), name: "mac", endpoint: endpoint)
|
||
}
|
||
|
||
private static func shareURL(_ origin: String) throws -> URL {
|
||
try #require(URL(string: "\(origin)/?join=\(validSessionId)"))
|
||
}
|
||
|
||
@Test("origin 命中已配对主机 → 恰一次 openSession,无 hint")
|
||
func knownOriginOpensSession() async throws {
|
||
let host = try Self.makeHost("http://192.168.1.5:3000")
|
||
let recorder = ActionRecorder()
|
||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||
await handler.markReady()
|
||
|
||
await handler.handle(url: try Self.shareURL("http://192.168.1.5:3000"))
|
||
|
||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||
#expect(recorder.opened.count == 1)
|
||
#expect(recorder.opened.first?.host == host)
|
||
#expect(recorder.opened.first?.sessionId == sessionId)
|
||
#expect(handler.hintMessage == nil)
|
||
}
|
||
|
||
@Test("origin 未配对 → 零 open + 落配对流程(绝不据链接静默新增主机)")
|
||
func unknownOriginNeverPairsSilently() async throws {
|
||
let paired = try Self.makeHost("http://192.168.1.5:3000")
|
||
let recorder = ActionRecorder()
|
||
let handler = DeepLinkHandler(loadHosts: { [paired] }, actions: recorder.actions)
|
||
await handler.markReady()
|
||
|
||
await handler.handle(url: try Self.shareURL("http://10.0.0.9:3000"))
|
||
|
||
#expect(recorder.opened.isEmpty)
|
||
#expect(recorder.pairingShownCount == 1)
|
||
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
|
||
}
|
||
|
||
@Test("提示文案不回显链接内容(防钓鱼/防日志注入)")
|
||
func hintNeverEchoesLinkContents() async throws {
|
||
let recorder = ActionRecorder()
|
||
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||
await handler.markReady()
|
||
|
||
await handler.handle(url: try Self.shareURL("http://evil.example:3000"))
|
||
|
||
let hint = try #require(handler.hintMessage)
|
||
#expect(!hint.contains("evil.example"))
|
||
#expect(!hint.contains(Self.validSessionId))
|
||
}
|
||
|
||
@Test("origin 比对经 originHeader:大小写/尾斜杠同一主机,端口不同则不是")
|
||
func originComparisonUsesOriginHeader() async throws {
|
||
let host = try Self.makeHost("http://mac.local:3000")
|
||
let recorder = ActionRecorder()
|
||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||
await handler.markReady()
|
||
|
||
await handler.handle(url: try Self.shareURL("http://MAC.LOCAL:3000"))
|
||
#expect(recorder.opened.count == 1)
|
||
|
||
await handler.handle(url: try Self.shareURL("http://mac.local:3001"))
|
||
#expect(recorder.opened.count == 1) // 端口不同 → 不是同一主机
|
||
#expect(recorder.pairingShownCount == 1)
|
||
}
|
||
|
||
@Test("scheme 不同 → 不是同一主机")
|
||
func schemeMismatchIsDifferentHost() async throws {
|
||
let host = try Self.makeHost("http://mac.local")
|
||
let recorder = ActionRecorder()
|
||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||
await handler.markReady()
|
||
|
||
await handler.handle(url: try Self.shareURL("https://mac.local"))
|
||
|
||
#expect(recorder.opened.isEmpty)
|
||
#expect(recorder.pairingShownCount == 1)
|
||
}
|
||
|
||
@Test("冷启动 stash 对 .joinShared 同样生效(恰应用一次)")
|
||
func coldLaunchStashAppliesJoin() async throws {
|
||
let host = try Self.makeHost("http://mac.local:3000")
|
||
let recorder = ActionRecorder()
|
||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||
|
||
await handler.handle(url: try Self.shareURL("http://mac.local:3000"))
|
||
#expect(recorder.opened.isEmpty)
|
||
|
||
await handler.markReady()
|
||
#expect(recorder.opened.count == 1)
|
||
|
||
await handler.markReady()
|
||
#expect(recorder.opened.count == 1)
|
||
}
|
||
|
||
@Test("非法 web 链接 → ignoredCount+1 且不入 stash")
|
||
func invalidWebLinkCountedNeverStashed() async throws {
|
||
let host = try Self.makeHost("http://mac.local:3000")
|
||
let recorder = ActionRecorder()
|
||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||
|
||
await handler.handle(
|
||
url: try #require(URL(string: "http://mac.local:3000/?join=nope&x=1"))
|
||
)
|
||
#expect(handler.ignoredCount == 1)
|
||
|
||
await handler.markReady()
|
||
|
||
#expect(recorder.opened.isEmpty)
|
||
#expect(recorder.pairingShownCount == 0)
|
||
}
|
||
}
|