Files
web-terminal/ios/App/WebTermTests/NotificationActionHandlerTests.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

330 lines
14 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 APIClient
import Foundation
import HostRegistry
import Testing
import TestSupport
import UserNotifications
import WireProtocol
@testable import WebTerm
/// T-iOS-21 · NotificationActionHandler.handleAllow/Deny POST
/// /hook/decision403
/// 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("403token 过期/已用)→ 本地通知兜底提示进 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("无主机可配 → 不打开、不 crashApp 已被点按拉起,落在列表页即可)")
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)
}
}