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
105 lines
4.3 KiB
Swift
105 lines
4.3 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import Testing
|
||
import UserNotifications
|
||
import WireProtocol
|
||
@testable import WebTerm
|
||
|
||
/// T-iOS-21 · `NotificationActionHandler.parse`:payload 级白名单解析
|
||
///(push payload 是不可信外部输入——sessionId 复用 DeepLinkRouter 的冻结
|
||
/// v4 校验,token 校验 v4 形状后原样透传,任一非法 → .invalidPayload)。
|
||
@MainActor
|
||
@Suite("NotificationActionHandler.parse")
|
||
struct NotificationActionParseTests {
|
||
private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||
/// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。
|
||
private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f"
|
||
|
||
private static func gateUserInfo(
|
||
sessionId: String = sessionId, token: Any? = token
|
||
) -> [AnyHashable: Any] {
|
||
var info: [AnyHashable: Any] = [
|
||
"aps": ["category": "WEBTERM_GATE"],
|
||
"sessionId": sessionId,
|
||
"cls": "needs-input",
|
||
]
|
||
if let token { info["token"] = token }
|
||
return info
|
||
}
|
||
|
||
@Test("Allow 动作 + 合法 payload → .decision(.allow)")
|
||
func parseAllowAction() throws {
|
||
let parsed = NotificationActionHandler.parse(
|
||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||
userInfo: Self.gateUserInfo()
|
||
)
|
||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||
#expect(parsed == .decision(.allow, sessionId: sessionId, token: Self.token))
|
||
}
|
||
|
||
@Test("Deny 动作 + 合法 payload → .decision(.deny)")
|
||
func parseDenyAction() throws {
|
||
let parsed = NotificationActionHandler.parse(
|
||
actionIdentifier: GateNotificationCategory.denyActionId,
|
||
userInfo: Self.gateUserInfo()
|
||
)
|
||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||
#expect(parsed == .decision(.deny, sessionId: sessionId, token: Self.token))
|
||
}
|
||
|
||
@Test("决策动作缺 token / token 非字符串 / token 非 v4 形状 → .invalidPayload")
|
||
func parseDecisionRejectsBadToken() {
|
||
for bad in [nil, 42 as Any, "not-a-token", "11111111-2222-1333-8444-555555555555"] {
|
||
let parsed = NotificationActionHandler.parse(
|
||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||
userInfo: Self.gateUserInfo(token: bad)
|
||
)
|
||
#expect(parsed == .invalidPayload)
|
||
}
|
||
}
|
||
|
||
@Test("决策动作 sessionId 非法(非 v4 / 缺失)→ .invalidPayload")
|
||
func parseDecisionRejectsBadSessionId() {
|
||
let badId = NotificationActionHandler.parse(
|
||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||
userInfo: Self.gateUserInfo(sessionId: "garbage")
|
||
)
|
||
#expect(badId == .invalidPayload)
|
||
let missing = NotificationActionHandler.parse(
|
||
actionIdentifier: GateNotificationCategory.denyActionId,
|
||
userInfo: ["token": Self.token]
|
||
)
|
||
#expect(missing == .invalidPayload)
|
||
}
|
||
|
||
@Test("默认点按 + 合法 sessionId → .openSession(复用 DeepLinkRouter 同一解析)")
|
||
func parseDefaultTapRoutes() throws {
|
||
let parsed = NotificationActionHandler.parse(
|
||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||
userInfo: Self.gateUserInfo(token: nil) // done 类通知无 token,点按仍可路由
|
||
)
|
||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||
#expect(parsed == .openSession(sessionId: sessionId))
|
||
}
|
||
|
||
@Test("默认点按 + 非法 payload → .invalidPayload")
|
||
func parseDefaultTapRejectsGarbage() {
|
||
let parsed = NotificationActionHandler.parse(
|
||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||
userInfo: ["sessionId": "'; DROP TABLE sessions;--"]
|
||
)
|
||
#expect(parsed == .invalidPayload)
|
||
}
|
||
|
||
@Test("dismiss / 未知动作 id → .dismissed(no-op)")
|
||
func parseDismissAndUnknownActions() {
|
||
#expect(NotificationActionHandler.parse(
|
||
actionIdentifier: UNNotificationDismissActionIdentifier,
|
||
userInfo: Self.gateUserInfo()
|
||
) == .dismissed)
|
||
#expect(NotificationActionHandler.parse(
|
||
actionIdentifier: "EVIL_ACTION", userInfo: Self.gateUserInfo()
|
||
) == .dismissed)
|
||
}
|
||
}
|