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
This commit is contained in:
329
ios/App/WebTermTests/NotificationActionHandlerTests.swift
Normal file
329
ios/App/WebTermTests/NotificationActionHandlerTests.swift
Normal file
@@ -0,0 +1,329 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import TestSupport
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-21 · NotificationActionHandler.handle:Allow/Deny → POST
|
||||
/// /hook/decision(背景任务包住、403 → 本地通知兜底)、默认点按 →
|
||||
/// DeepLinkRouter 同源路由到会话(parse 的 payload 级用例在
|
||||
/// NotificationActionParseTests.swift)。
|
||||
/// 说明:UNNotificationResponse 无法在单测构造 —— didReceive 薄胶水不在
|
||||
/// 单测覆盖内,全部逻辑经 parse(actionIdentifier:userInfo:) + handle(_:) 测。
|
||||
@MainActor
|
||||
@Suite("NotificationActionHandler")
|
||||
struct NotificationActionHandlerTests {
|
||||
// MARK: - Fakes
|
||||
|
||||
@MainActor
|
||||
private final class FakeBackgroundTasks: BackgroundTaskRunning {
|
||||
private(set) var begunNames: [String] = []
|
||||
private(set) var endedTokens: [Int] = []
|
||||
private var nextToken = 0
|
||||
|
||||
func begin(name: String) -> Int {
|
||||
begunNames.append(name)
|
||||
nextToken += 1
|
||||
return nextToken
|
||||
}
|
||||
|
||||
func end(_ token: Int) { endedTokens.append(token) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeNoticePoster: LocalNoticePosting {
|
||||
private(set) var posted: [(title: String, body: String)] = []
|
||||
func post(title: String, body: String) async { posted.append((title, body)) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OpenRecorder {
|
||||
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
|
||||
var actions: NotificationActionHandler.Actions {
|
||||
NotificationActionHandler.Actions(openSession: { [weak self] host, sessionId in
|
||||
self?.opened.append((host, sessionId))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
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 let hostABase = "http://192.168.1.5:3000"
|
||||
private static let hostBBase = "http://192.168.1.6:3000"
|
||||
|
||||
private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
private struct Harness {
|
||||
let handler: NotificationActionHandler
|
||||
let http: FakeHTTPTransport
|
||||
let backgroundTasks: FakeBackgroundTasks
|
||||
let notices: FakeNoticePoster
|
||||
let recorder: OpenRecorder
|
||||
}
|
||||
|
||||
private static func makeHarness(hosts: [HostRegistry.Host]) -> Harness {
|
||||
let http = FakeHTTPTransport()
|
||||
let backgroundTasks = FakeBackgroundTasks()
|
||||
let notices = FakeNoticePoster()
|
||||
let recorder = OpenRecorder()
|
||||
let handler = NotificationActionHandler(
|
||||
hostStore: InMemoryHostStore(hosts: hosts),
|
||||
http: http,
|
||||
backgroundTasks: backgroundTasks,
|
||||
notices: notices,
|
||||
actions: recorder.actions
|
||||
)
|
||||
return Harness(
|
||||
handler: handler, http: http, backgroundTasks: backgroundTasks,
|
||||
notices: notices, recorder: recorder
|
||||
)
|
||||
}
|
||||
|
||||
private static func decisionURL(base: String) throws -> URL {
|
||||
try #require(URL(string: "\(base)/hook/decision"))
|
||||
}
|
||||
|
||||
private static func liveSessionsURL(base: String) throws -> URL {
|
||||
try #require(URL(string: "\(base)/live-sessions"))
|
||||
}
|
||||
|
||||
/// 多主机场景的两条 RO 探查响应一次排好(A 无、B 持有目标会话)。
|
||||
private static func queueTwoHostProbe(_ harness: Harness) async throws {
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try liveSessionsURL(base: hostABase),
|
||||
body: liveSessionsBody(containing: nil)
|
||||
)
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try liveSessionsURL(base: hostBBase),
|
||||
body: liveSessionsBody(containing: sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
/// /live-sessions 响应体:可选包含目标会话(必填字段齐全的最小形状)。
|
||||
private static func liveSessionsBody(containing sessionId: String?) -> Data {
|
||||
guard let sessionId else { return Data("[]".utf8) }
|
||||
let json = """
|
||||
[{"id":"\(sessionId)","createdAt":1,"clientCount":0,"status":"waiting",
|
||||
"exited":false,"cols":80,"rows":24}]
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
// MARK: - handle(.decision):POST /hook/decision
|
||||
|
||||
@Test("单主机 Allow → 直接 POST(免 RO 探查),体为 {sessionId,decision,token},带 Origin,背景任务成对")
|
||||
func decisionSingleHostPosts() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let requests = await harness.http.recordedRequests
|
||||
#expect(requests.count == 1)
|
||||
let request = try #require(requests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == Self.hostABase)
|
||||
let body = try JSONDecoder().decode(
|
||||
[String: String].self, from: try #require(request.httpBody)
|
||||
)
|
||||
#expect(body == [
|
||||
"sessionId": Self.sessionId, "decision": "allow", "token": Self.token,
|
||||
])
|
||||
#expect(harness.backgroundTasks.begunNames.count == 1)
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Deny → decision 字段为 deny")
|
||||
func decisionDenyPostsDeny() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.deny, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let request = try #require(await harness.http.recordedRequests.first)
|
||||
let body = try JSONDecoder().decode(
|
||||
[String: String].self, from: try #require(request.httpBody)
|
||||
)
|
||||
#expect(body["decision"] == "deny")
|
||||
}
|
||||
|
||||
@Test("403(token 过期/已用)→ 本地通知兜底提示进 App;文案不含 token;背景任务成对")
|
||||
func decisionRejectedPostsExpiredNotice() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 403
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
#expect(harness.notices.posted.count == 1)
|
||||
let notice = try #require(harness.notices.posted.first)
|
||||
#expect(notice.title == PushDecisionCopy.expiredTitle)
|
||||
#expect(notice.body == PushDecisionCopy.expiredBody)
|
||||
// capability token 用后即弃:绝不出现在任何兜底文案里。
|
||||
#expect(!notice.body.contains(Self.token) && !notice.title.contains(Self.token))
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
}
|
||||
|
||||
@Test("传输层失败 → 本地通知兜底(失败可见,绝不静默吞);背景任务成对")
|
||||
func decisionTransportFailurePostsFailedNotice() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueFailure(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase),
|
||||
error: URLError(.cannotConnectToHost)
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
#expect(harness.notices.posted.count == 1)
|
||||
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
|
||||
#expect(harness.backgroundTasks.begunNames.count == 1)
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
}
|
||||
|
||||
@Test("多主机 → 先 RO 查 /live-sessions(无 Origin)定位持有会话的主机,再对其 POST")
|
||||
func decisionMultiHostResolvesOwner() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
try await Self.queueTwoHostProbe(harness)
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostBBase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let requests = await harness.http.recordedRequests
|
||||
let gets = requests.filter { $0.httpMethod == "GET" }
|
||||
#expect(gets.count == 2)
|
||||
for get in gets { // RO 铁律:探查一律不带 Origin
|
||||
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
let posts = requests.filter { $0.httpMethod == "POST" }
|
||||
#expect(posts.count == 1)
|
||||
#expect(posts.first?.url == (try Self.decisionURL(base: Self.hostBBase)))
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("多主机但会话无处可寻 → 不 POST,本地通知兜底")
|
||||
func decisionUnresolvableHostFailsVisibly() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try Self.liveSessionsURL(base: Self.hostABase),
|
||||
body: Self.liveSessionsBody(containing: nil)
|
||||
)
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try Self.liveSessionsURL(base: Self.hostBBase),
|
||||
body: Self.liveSessionsBody(containing: nil)
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let posts = await harness.http.recordedRequests.filter { $0.httpMethod == "POST" }
|
||||
#expect(posts.isEmpty)
|
||||
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
|
||||
}
|
||||
|
||||
// MARK: - handle(.openSession):默认点按路由
|
||||
|
||||
@Test("单主机点按 → openSession(host, id),不发决策 POST,也不开背景任务")
|
||||
func openSessionSingleHostRoutes() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.count == 1)
|
||||
#expect(harness.recorder.opened.first?.host == host)
|
||||
#expect(harness.recorder.opened.first?.sessionId == sessionId)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
#expect(harness.backgroundTasks.begunNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("多主机点按 → RO 定位后打开持有主机")
|
||||
func openSessionMultiHostResolves() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
try await Self.queueTwoHostProbe(harness)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.first?.host == hostB)
|
||||
}
|
||||
|
||||
@Test("无主机可配 → 不打开、不 crash(App 已被点按拉起,落在列表页即可)")
|
||||
func openSessionUnresolvedIsSafeNoOp() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 其余分支
|
||||
|
||||
@Test(".invalidPayload → 计数 +1,无网络、无通知、无路由")
|
||||
func invalidPayloadCountedOnly() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
|
||||
await harness.handler.handle(.invalidPayload)
|
||||
|
||||
#expect(harness.handler.invalidPayloadCount == 1)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
#expect(harness.recorder.opened.isEmpty)
|
||||
}
|
||||
|
||||
@Test(".dismissed → 完全 no-op")
|
||||
func dismissedIsNoOp() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
|
||||
await harness.handler.handle(.dismissed)
|
||||
|
||||
#expect(harness.handler.invalidPayloadCount == 0)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user