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 = .success(true) private(set) var requestedOptions: [UNAuthorizationOptions] = [] private(set) var registeredCategorySets: [Set] = [] 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) { 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) } }