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

303 lines
12 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 Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-35 · web QR`?join=`doc §4 AC 3050
///
/// 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 键 → .ignoreweb 形状精确只有一个 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)
}
}