import Foundation import HostRegistry import Testing import WireProtocol @testable import WebTerm /// T-iOS-22 · Deep-link routing: `webterminal://open?host=&join=` /// 解析(全字段白名单,任一非法 → .ignore,绝不部分应用)、WEBTERM_GATE push /// payload 的同源校验入口,以及 DeepLinkHandler 的冷启动 stash / 未知 host /// 落配对页 / 忽略计数。 @MainActor @Suite("DeepLinkRouter") struct DeepLinkRouterTests { // MARK: - Fixtures /// v4 UUID(version nibble=4、variant=8)——通过 Validation.isValidSessionId。 private static let validHostId = "11111111-2222-4333-8444-555555555555" private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a" /// version nibble=1:Foundation 的 UUID(uuidString:) 接受,但服务器的 /// SESSION_ID_RE(v4 专属)拒绝——用它钉住"复用 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) @Test("scheme 非 webterminal → .ignore") func wrongSchemeIgnored() throws { let route = DeepLinkRouter.route( url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)") ) #expect(route == .ignore) } @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 非 v4(version 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 payload(WEBTERM_GATE,T-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 id(UUID 合法但不在 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) } }