Files
web-terminal/ios/App/WebTermTests/DeepLinkRouterTests.swift
Yaojia Wang f40b8f9400 feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
2026-07-05 16:15:57 +02:00

339 lines
13 KiB
Swift
Raw Permalink 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-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
/// .ignoreWEBTERM_GATE push
/// payload DeepLinkHandler stash / host
/// /
@MainActor
@Suite("DeepLinkRouter")
struct DeepLinkRouterTests {
// MARK: - Fixtures
/// v4 UUIDversion nibble=4variant=8 Validation.isValidSessionId
private static let validHostId = "11111111-2222-4333-8444-555555555555"
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// version nibble=1Foundation UUID(uuidString:)
/// SESSION_ID_REv4 " 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 非 v4version 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 payloadWEBTERM_GATET-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 idUUID 合法但不在 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)
}
}