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

367 lines
15 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 · WEBTERM_GATE category Allow
/// `.authenticationRequired` `.foreground`
@MainActor
@Suite("GateNotificationCategory")
struct GateNotificationCategoryTests {
@Test("category id 与服务器 GATE_CATEGORY 一致src/push/apns.ts:52")
func categoryIdentifierMatchesServer() {
#expect(GateNotificationCategory.category().identifier == "WEBTERM_GATE")
}
@Test("恰好两动作,顺序 Allow→Deny标题为中文具名常量")
func actionsShapeAndTitles() {
let actions = GateNotificationCategory.category().actions
#expect(actions.count == 2)
#expect(actions.first?.identifier == GateNotificationCategory.allowActionId)
#expect(actions.last?.identifier == GateNotificationCategory.denyActionId)
#expect(actions.first?.title == GateNotificationCategory.allowTitle)
#expect(actions.last?.title == GateNotificationCategory.denyTitle)
}
@Test("安全注Allow 必须带 .authenticationRequired锁屏批准 = 授权主机执行命令)")
func allowRequiresAuthentication() {
let allow = GateNotificationCategory.category().actions.first
#expect(allow?.options.contains(.authenticationRequired) == true)
}
@Test("安全注Deny 保持免认证fail-safe——旁观者只能拒绝")
func denyStaysUnauthenticated() {
let deny = GateNotificationCategory.category().actions.last
#expect(deny?.options.contains(.authenticationRequired) == false)
}
@Test("安全注:两动作都不带 .foreground锁屏两次手势闭环不拉起 UI")
func neitherActionForegrounds() {
let actions = GateNotificationCategory.category().actions
for action in actions {
#expect(!action.options.contains(.foreground))
}
}
}
/// T-iOS-21 · PushRegistrar provisional
/// alert device token
@MainActor
@Suite("PushRegistrar")
struct PushRegistrarTests {
// MARK: - Fakesseam UNUserNotificationCenter
@MainActor
private final class FakeNotificationCenter: NotificationCenterClient {
var status: UNAuthorizationStatus = .notDetermined
var grantResult: Result<Bool, any Error> = .success(true)
private(set) var requestedOptions: [UNAuthorizationOptions] = []
private(set) var registeredCategorySets: [Set<UNNotificationCategory>] = []
private(set) var addedRequests: [UNNotificationRequest] = []
func authorizationStatus() async -> UNAuthorizationStatus { status }
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
requestedOptions.append(options)
return try grantResult.get()
}
func setNotificationCategories(_ categories: Set<UNNotificationCategory>) {
registeredCategorySets.append(categories)
}
func add(_ request: UNNotificationRequest) async throws {
addedRequests.append(request)
}
}
@MainActor
private final class FakeRemoteRegistrar: RemoteNotificationRegistering {
private(set) var registerCallCount = 0
func registerForRemoteNotifications() { registerCallCount += 1 }
}
private enum StubError: Error { case authorizationFailed }
// MARK: - Fixtures
private static let hostABase = "http://192.168.1.5:3000"
private static let hostBBase = "http://192.168.1.6:3000"
/// 32 device token 64 hexAPNs
private static let tokenData = Data((0..<32).map { UInt8($0) })
private static let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
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 static func apnsTokenURL(base: String) throws -> URL {
try #require(URL(string: "\(base)/push/apns-token"))
}
private static func makeRegistrar(
hosts: [HostRegistry.Host],
http: FakeHTTPTransport,
center: FakeNotificationCenter,
remote: FakeRemoteRegistrar
) -> PushRegistrar {
PushRegistrar(
hostStore: InMemoryHostStore(hosts: hosts),
http: http,
center: center,
remote: remote
)
}
// MARK: - activate()
@Test("activate → 注册含 WEBTERM_GATE 的 category 集合")
func activateRegistersGateCategory() async throws {
let center = FakeNotificationCenter()
let registrar = Self.makeRegistrar(
hosts: [], http: FakeHTTPTransport(), center: center, remote: FakeRemoteRegistrar()
)
await registrar.activate()
let registered = try #require(center.registeredCategorySets.first)
#expect(registered.contains(where: { $0.identifier == GateNotificationCategory.identifier }))
}
@Test("无已配对主机 → 不请求授权、不注册远程通知(无推送来源不打扰)")
func activateWithoutHostsSkipsAuthorization() async {
let center = FakeNotificationCenter()
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [], http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions.isEmpty)
#expect(remote.registerCallCount == 0)
}
@Test("有主机 + notDetermined + 授予 → 以 [.alert,.sound] 请求(真授权,非 provisional并注册远程通知")
func activateRequestsRealAuthorizationAndRegisters() async throws {
let center = FakeNotificationCenter()
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions == [[.alert, .sound]])
// provisional Allow/Deny alert
#expect(center.requestedOptions.first?.contains(.provisional) == false)
#expect(remote.registerCallCount == 1)
}
@Test("用户拒绝授权 → 不注册远程通知、不 crash")
func activateDeniedGrantSkipsRegistration() async throws {
let center = FakeNotificationCenter()
center.grantResult = .success(false)
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(remote.registerCallCount == 0)
}
@Test("授权请求抛错 → 记日志、不注册、不 crash")
func activateAuthorizationErrorHandled() async throws {
let center = FakeNotificationCenter()
center.grantResult = .failure(StubError.authorizationFailed)
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(remote.registerCallCount == 0)
}
@Test("状态已 denied → 不再弹请求,也不注册")
func activateDeniedStatusShortCircuits() async throws {
let center = FakeNotificationCenter()
center.status = .denied
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions.isEmpty)
#expect(remote.registerCallCount == 0)
}
@Test("状态已 authorized → 跳过请求直接注册远程通知")
func activateAuthorizedStatusRegistersDirectly() async throws {
let center = FakeNotificationCenter()
center.status = .authorized
let remote = FakeRemoteRegistrar()
let registrar = Self.makeRegistrar(
hosts: [try Self.makeHost(base: Self.hostABase)],
http: FakeHTTPTransport(), center: center, remote: remote
)
await registrar.activate()
#expect(center.requestedOptions.isEmpty)
#expect(remote.registerCallCount == 1)
}
// MARK: - handleDeviceToken
@Test("device token → 小写 hex并对每个已配对主机 POST /push/apns-token带 Origin")
func deviceTokenRegistersWithEveryHost() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
let http = FakeHTTPTransport()
await http.queueSuccess(
method: "POST", url: try Self.apnsTokenURL(base: Self.hostABase), status: 204
)
await http.queueSuccess(
method: "POST", url: try Self.apnsTokenURL(base: Self.hostBBase), status: 204
)
let registrar = Self.makeRegistrar(
hosts: [hostA, hostB], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
#expect(registrar.currentTokenHex == Self.tokenHex)
let requests = await http.recordedRequests
#expect(requests.count == 2)
let urls = Set(requests.compactMap { $0.url?.absoluteString })
#expect(urls == [
"\(Self.hostABase)/push/apns-token", "\(Self.hostBBase)/push/apns-token",
])
for request in requests {
#expect(request.httpMethod == "POST")
// G Origin endpoint.originHeader
let origin = request.value(forHTTPHeaderField: "Origin")
#expect(origin == request.url?.absoluteString.replacingOccurrences(
of: "/push/apns-token", with: ""
))
let body = try #require(request.httpBody)
let decoded = try JSONDecoder().decode([String: String].self, from: body)
#expect(decoded == ["token": Self.tokenHex])
}
}
@Test("一主机失败 → 其余主机照常注册;同 token 重试只补失败的主机")
func failedHostRetriedNextTime() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
let http = FakeHTTPTransport()
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
let urlB = try Self.apnsTokenURL(base: Self.hostBBase)
await http.queueFailure(method: "POST", url: urlA, error: URLError(.cannotConnectToHost))
await http.queueSuccess(method: "POST", url: urlB, status: 204)
let registrar = Self.makeRegistrar(
hosts: [hostA, hostB], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
#expect(await http.recordedRequests.count == 2)
// token / hostA
await http.queueSuccess(method: "POST", url: urlA, status: 204)
await registrar.handleDeviceToken(Self.tokenData)
let requests = await http.recordedRequests
#expect(requests.count == 3)
#expect(requests.last?.url == urlA)
}
@Test("token 变化 → 对全部主机重新注册")
func tokenChangeReRegistersAllHosts() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let http = FakeHTTPTransport()
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
await http.queueSuccess(method: "POST", url: urlA, status: 204)
await http.queueSuccess(method: "POST", url: urlA, status: 204)
let registrar = Self.makeRegistrar(
hosts: [hostA], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
let changed = Data((0..<32).map { UInt8($0 &+ 1) })
await registrar.handleDeviceToken(changed)
#expect(await http.recordedRequests.count == 2)
#expect(registrar.currentTokenHex == changed.map { String(format: "%02x", $0) }.joined())
}
// MARK: - additive hook UI
@Test("handleHostRemoved有 token→ 对该主机 DELETE /push/apns-token")
func hostRemovedUnregistersToken() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let http = FakeHTTPTransport()
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
await http.queueSuccess(method: "POST", url: urlA, status: 204)
await http.queueSuccess(method: "DELETE", url: urlA, status: 204)
let registrar = Self.makeRegistrar(
hosts: [hostA], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleDeviceToken(Self.tokenData)
await registrar.handleHostRemoved(hostA)
let last = try #require(await http.recordedRequests.last)
#expect(last.httpMethod == "DELETE")
#expect(last.url == urlA)
let body = try #require(last.httpBody)
#expect(try JSONDecoder().decode([String: String].self, from: body)
== ["token": Self.tokenHex])
}
@Test("handleHostRemoved无 token→ 不发任何请求")
func hostRemovedWithoutTokenIsNoOp() async throws {
let hostA = try Self.makeHost(base: Self.hostABase)
let http = FakeHTTPTransport()
let registrar = Self.makeRegistrar(
hosts: [hostA], http: http,
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
await registrar.handleHostRemoved(hostA)
#expect(await http.recordedRequests.isEmpty)
}
@Test("远程注册失败回调 → 只记日志,不 crash")
func registrationFailureLoggedOnly() throws {
let registrar = Self.makeRegistrar(
hosts: [], http: FakeHTTPTransport(),
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
)
registrar.handleRegistrationFailure(URLError(.notConnectedToInternet))
#expect(registrar.currentTokenHex == nil)
}
}