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
367 lines
15 KiB
Swift
367 lines
15 KiB
Swift
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: - Fakes(seam 替身;真 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 位小写 hex(APNs 现行长度)。
|
||
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)
|
||
}
|
||
}
|