T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
339 lines
13 KiB
Swift
339 lines
13 KiB
Swift
import Foundation
|
||
import HostRegistry
|
||
import Testing
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
|
||
/// 解析(全字段白名单,任一非法 → .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)
|
||
}
|
||
}
|