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) } }