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://:3000/?join= → .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) } }