import Foundation import Testing import UIKit @testable import Sybil @MainActor private final class PushMemory { var value = AgentPushPersistence() var failWrite = false var storage: AgentPushStorage { .init(read: { self.value }, write: { value in if self.failWrite { throw URLError(.cannotWriteToFile) } self.value = value }) } } @MainActor private final class PushPlatform: AgentNotificationPlatform { var environment: AgentAPNSEnvironment? = .sandbox var allowsLoopback = true var authorization: AgentNotificationPermission = .notDetermined var allow = true var prompts = 0 var registrations: [UUID] = [] var unregisters = 0 var notifications: [AgentDeliveredNotification] = [] var removed: [String] = [] var holdPermission = false var holdPermissionRead = false var permissionReadWaiter: CheckedContinuation? var permissionWaiter: CheckedContinuation? func permission() async -> AgentNotificationPermission { if holdPermissionRead { return await withCheckedContinuation { permissionReadWaiter = $0 } } return authorization } func requestPermission() async -> Bool { prompts += 1 if holdPermission { return await withCheckedContinuation { permissionWaiter = $0 } } authorization = allow ? .authorized : .denied return allow } func register(generation: UUID) { registrations.append(generation) } func unregister() { unregisters += 1 } func delivered() async -> [AgentDeliveredNotification] { notifications } func removeDelivered(identifiers: [String]) { removed += identifiers; notifications.removeAll { identifiers.contains($0.identifier) } } } private actor PushAPI: AgentPushAPIClienting { var config = AgentPushConfig(version: 1, enabled: true, apnsAvailable: true, webPushAvailable: false, sourceId: "11111111-1111-4111-8111-111111111111", quarantined: false) var read = AgentReadState(cursor: "0", sourceId: "11111111-1111-4111-8111-111111111111") var puts: [(UUID, AgentPushRegistration)] = [] var deletes: [UUID] = [] var failures = 0 var offline = false var oldServer = false var revokeStatus: Int? var holdPut = false var putWaiter: CheckedContinuation? var holdConfig = false var configWaiter: CheckedContinuation? func agentPushConfig() async throws -> AgentPushConfig { if holdConfig { await withCheckedContinuation { configWaiter = $0 } } if oldServer { throw URLError(.badServerResponse) } return config } func agentReadState() -> AgentReadState { read } func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt { puts.append((id, registration)) if holdPut { await withCheckedContinuation { putWaiter = $0 } } if failures > 0 { failures -= 1; throw URLError(.networkConnectionLost) } return .init(installationId: id.uuidString, transport: "apns", enabled: true, version: puts.count) } func agentRevokePush(id: UUID) throws { deletes.append(id) if let revokeStatus { throw AgentPushRequestError(status: revokeStatus) } if offline { throw URLError(.notConnectedToInternet) } } func failRevoke(status: Int) { revokeStatus = status } func set(failures: Int = 0, offline: Bool = false, holdPut: Bool = false, oldServer: Bool = false, holdConfig: Bool = false) { self.failures = failures; self.offline = offline; self.holdPut = holdPut; self.oldServer = oldServer; self.holdConfig = holdConfig } func source(_ id: String) { config.sourceId = id; read.sourceId = id } func setAvailable(_ available: Bool) { config.enabled = available } func releasePut() { holdPut = false; putWaiter?.resume(); putWaiter = nil } func releaseConfig() { holdConfig = false; configWaiter?.resume(); configWaiter = nil } func requests() -> [(UUID, AgentPushRegistration)] { puts } func revokes() -> [UUID] { deletes } func waitingPut() -> Bool { putWaiter != nil } func waitingConfig() -> Bool { configWaiter != nil } } @MainActor private func pushEventually(_ predicate: () async -> Bool) async -> Bool { for _ in 0..<300 { if await predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) } return false } private func pushPayload(_ cursor: String = "4", source: String = "11111111-1111-4111-8111-111111111111", kind: String = "message") -> AgentPushPayload { AgentPushPayload(["version": 1, "sourceId": source, "kind": kind, "cursor": cursor, "readCursor": cursor, "messageId": "outbound:\(cursor)"])! } @MainActor private func bindPush(_ notifications: SybilAgentNotifications, _ api: PushAPI, url: String = "https://example.test/api", mode: String = "token") async { await notifications.bind(client: api, baseURL: URL(string: url)!, authenticated: true, mode: mode, token: mode == "token" ? "fixture-token" : nil) } @Suite(.serialized) struct AgentNotificationTests { @Test func payloadValidationAndCanonicalScope() throws { #expect(AgentPushPayload(["version": true, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil) #expect(AgentPushPayload(["version": 2, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil) #expect(AgentPushPayload(["version": 1, "sourceId": "s", "kind": "message", "cursor": 1, "messageId": "m"]) == nil) for cursor in ["-1", "1e3", "9007199254740992", "1.0", ""] { #expect(AgentPushPayload.validCursor(cursor) == nil) } #expect(AgentPushPayload.validCursor("0004") == "4") #expect(AgentPushScope.canonicalBackend(URL(string: "https://EXAMPLE.test:443/api/")!) == "https://example.test/api") for url in ["https://user:secret@example.test/api", "https://example.test/api?token=bad", "https://example.test/#fragment", "http://example.test/api", "https://example.test/api/../other"] { #expect(AgentPushScope.canonicalBackend(URL(string: url)!) == nil) } #expect(AgentAPNSEnvironment(entitlementSetting: "production") == .production) #expect(AgentAPNSEnvironment(entitlementSetting: "development") == .sandbox) #expect(AgentAPNSEnvironment(entitlementSetting: "DEBUG") == nil) } @Test @MainActor func explicitAllowFreshTokenRotationAndIdempotence() async throws { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api) #expect(platform.prompts == 0 && platform.registrations.isEmpty) await push.enable() #expect(platform.prompts == 1 && platform.registrations.count == 1) #expect(await api.requests().isEmpty) let run = push.generation push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run) #expect(await pushEventually { await api.requests().count == 1 && memory.value.records.first?.version != nil }) let first = try #require(await api.requests().first) #expect(first.1.token == "00010aff" && first.1.environment == .sandbox) push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run) #expect(await api.requests().count == 1) push.receivedDeviceToken(Data([0x42, 0xab]), generation: run) #expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version == 2 }) let next = try #require(await api.requests().last) #expect(first.0 == next.0 && next.1.token == "42ab") let encoded = String(decoding: try JSONEncoder().encode(memory.value), as: UTF8.self) #expect(!encoded.contains("00010aff") && !encoded.contains("42ab")) await push.refresh() #expect(platform.prompts == 1 && platform.registrations.count == 2) let relaunchedPlatform = PushPlatform(); relaunchedPlatform.authorization = .authorized let relaunched = SybilAgentNotifications(platform: relaunchedPlatform, storage: memory.storage) await bindPush(relaunched, api) #expect(relaunchedPlatform.registrations.count == 1 && relaunchedPlatform.prompts == 0) #expect(await api.requests().count == 2) // persisted token is never used. } @Test @MainActor func deniedOpenOldServerAndPersistenceFailureNeverEnroll() async { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api, mode: "open"); await push.enable() #expect(platform.prompts == 0 && platform.registrations.isEmpty) await api.set(oldServer: true); await bindPush(push, api) #expect(!push.available && platform.prompts == 0) await api.set(); await bindPush(push, api) platform.allow = false; await push.enable() #expect(platform.prompts == 1 && !push.optedIn && platform.registrations.isEmpty) platform.authorization = .authorized; memory.failWrite = true await push.enable() #expect(platform.registrations.isEmpty) #expect(await api.requests().isEmpty) } @Test @MainActor func retryThenRevokeUsesRetiredUUID() async throws { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api); await push.enable(); await api.set(failures: 1) push.receivedDeviceToken(Data([1]), generation: push.generation) #expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil }) let ids = await api.requests().map(\.0) #expect(Set(ids).count == 1) await push.disable() #expect(await api.revokes().contains(ids[0])) #expect(!push.optedIn && push.pendingRevocations == 0) await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation) #expect(await pushEventually { await api.requests().count == 3 }) #expect(await api.requests().last?.0 != ids[0]) } @Test @MainActor func logoutAndServerSwitchRejectLateOSAndUploadCallbacks() async throws { let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, old); await push.enable(); await old.set(holdPut: true) let oldRun = push.generation push.receivedDeviceToken(Data([1]), generation: oldRun) #expect(await pushEventually { await old.waitingPut() }) let oldID = try #require(await old.requests().first?.0) await push.deactivate() await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test/api") await push.enable() push.receivedDeviceToken(Data([0xff]), generation: oldRun) #expect(await next.requests().isEmpty) await old.releasePut() #expect(await pushEventually { await old.revokes().contains(oldID) }) #expect(await next.revokes().isEmpty) #expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222" && push.optedIn) push.receivedDeviceToken(Data([2]), generation: push.generation) #expect(await pushEventually { await next.requests().count == 1 }) } @Test @MainActor func oldConfigAndPermissionCannotBindNewSession() async { let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await old.set(holdConfig: true) let binding = Task { await bindPush(push, old) } #expect(await pushEventually { await old.waitingConfig() }) await push.deactivate(); await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test") await old.releaseConfig(); await binding.value #expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222") platform.holdPermission = true let enabling = Task { await push.enable() } #expect(await pushEventually { platform.permissionWaiter != nil }) await push.deactivate() platform.permissionWaiter?.resume(returning: true); await enabling.value #expect(platform.registrations.isEmpty && !push.optedIn) } @Test @MainActor func offlineTombstoneSurvivesRelaunchAndOnlyExactScopeRetries() async throws { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api); await push.enable() push.receivedDeviceToken(Data([1]), generation: push.generation) #expect(await pushEventually { memory.value.records.first?.version != nil }) await api.set(offline: true); await push.deactivate() #expect(memory.value.tombstones.count == 1) let id = try #require(memory.value.tombstones.first?.installationID) let next = PushAPI(), relaunched = SybilAgentNotifications(platform: PushPlatform(), storage: memory.storage) await bindPush(relaunched, next, url: "https://other.test/api") #expect(await next.revokes().isEmpty) await relaunched.deactivate(); await next.source("33333333-3333-4333-8333-333333333333") await bindPush(relaunched, next) #expect(await next.revokes().isEmpty) await relaunched.deactivate(); await next.source("11111111-1111-4111-8111-111111111111"); await bindPush(relaunched, next) #expect(await next.revokes() == [id] && memory.value.tombstones.isEmpty) } @Test @MainActor func deliveredClearUsesSystemIdentifiersAndRetainsNewerOrUnrelated() async { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api) platform.notifications = [ .init(identifier: "apns-system-a", payload: pushPayload("3")), .init(identifier: "apns-system-b", payload: pushPayload("4")), .init(identifier: "newer", payload: pushPayload("5")), .init(identifier: "foreign", payload: pushPayload("1", source: "44444444-4444-4444-8444-444444444444")), .init(identifier: "unrelated", payload: nil)] await push.acknowledged(.init(cursor: "99"), generation: push.generation) #expect(platform.removed.isEmpty) // legacy reads have no source authority. await push.acknowledged(.init(cursor: "4", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation) #expect(platform.removed == ["apns-system-a", "apns-system-b"]) #expect(platform.notifications.map(\.identifier) == ["newer", "foreign", "unrelated"]) await push.acknowledged(.init(cursor: "1", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation) #expect(!push.shouldPresent(pushPayload("4"))) #expect(push.shouldPresent(pushPayload("5"))) let scene = UUID(); push.setScene(scene, active: true) #expect(!push.shouldPresent(pushPayload("5"))) push.setScene(scene, active: false) #expect(await push.receiveBackground(pushPayload("5", kind: "read_state"))) #expect(platform.removed.last == "newer") #expect(await api.requests().isEmpty) // no read/enrollment side effect. } @Test @MainActor func coldTapWaitsForAuthenticationDeduplicatesAndDoesNotRead() async { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) let payload = pushPayload() push.notificationResponse(payload, identifier: "system-1") #expect(!push.consumeRoute()) let request = push.pendingRouteID push.notificationResponse(payload, identifier: "system-2") #expect(push.pendingRouteID == request) await bindPush(push, api, mode: "open"); #expect(!push.consumeRoute()) await bindPush(push, api); #expect(push.consumeRoute()); #expect(!push.consumeRoute()) #expect(platform.removed.isEmpty) #expect(await api.requests().isEmpty) #expect(push.shouldPresent(payload)) // tap alone did not advance read. push.notificationResponse(pushPayload(source: "44444444-4444-4444-8444-444444444444"), identifier: "wrong") #expect(!push.consumeRoute()) } @Test @MainActor func backgroundCompletionExactlyOnceIncludingTimeout() async { var outcomes: [UIBackgroundFetchResult] = [] AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { .newData }, completion: { outcomes.append($0) }) #expect(await pushEventually { outcomes.count == 1 }) try? await Task.sleep(for: .milliseconds(40)); #expect(outcomes == [.newData]) var continuation: CheckedContinuation? AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { await withCheckedContinuation { continuation = $0 } }, completion: { outcomes.append($0) }) #expect(await pushEventually { outcomes.count == 2 }) continuation?.resume(returning: .newData) try? await Task.sleep(for: .milliseconds(20)); #expect(outcomes == [.newData, .noData]) } @Test @MainActor func changedCredentialEpochRequiresConsentAndRejectsColdReadHints() async throws { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let original = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(original, api); await original.enable() original.receivedDeviceToken(Data([1]), generation: original.generation) #expect(await pushEventually { memory.value.records.first?.version != nil }) let replacementPlatform = PushPlatform(); replacementPlatform.authorization = .authorized let replacement = SybilAgentNotifications(platform: replacementPlatform, storage: memory.storage) replacement.restoreBackgroundScope(baseURL: URL(string: "https://example.test/api")!, token: "replacement-fixture-token", payload: pushPayload(kind: "read_state")) #expect(replacement.scope == nil) await replacement.bind(client: api, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token") #expect(!replacement.optedIn && replacementPlatform.registrations.isEmpty) #expect(memory.value.records.count == 2) #expect(memory.value.records[0].scope.credentialScope != memory.value.records[1].scope.credentialScope) #expect(await api.revokes().isEmpty) #expect(replacement.scope?.credentialScope == memory.value.records.last?.scope.credentialScope) } @Test @MainActor func deniedPermissionThenAuthorizedRefreshRecovers() async { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() platform.authorization = .denied let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api) #expect(push.permission == .denied) platform.authorization = .authorized await push.refresh() #expect(push.permission == .authorized && push.available) await push.enable() #expect(platform.registrations.count == 1) } @Test @MainActor func equivalentCanonicalBackendDoesNotRetireConsent() async { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api); await push.enable() let run = push.generation await bindPush(push, api, url: "https://EXAMPLE.test:443/api/") #expect(push.generation == run && push.optedIn) #expect(memory.value.records.count == 1) } @Test @MainActor func sameBackendCredentialRotationWith403TombstoneAllowsNewConsentRoutingAndClear() async { let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, old); await push.enable() push.receivedDeviceToken(Data([1]), generation: push.generation) #expect(await pushEventually { memory.value.records.first?.version != nil }) await old.failRevoke(status: 403) await push.bind(client: next, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token") #expect(!push.optedIn && memory.value.tombstones.count == 1) await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation) #expect(await pushEventually { await next.requests().count == 1 && push.status == "Notifications are enabled." }) push.notificationResponse(pushPayload(), identifier: "system-after-rotation") #expect(push.consumeRoute()) platform.notifications = [.init(identifier: "actual-system-id", payload: pushPayload())] #expect(await push.receiveBackground(pushPayload(kind: "read_state"))) #expect(platform.removed == ["actual-system-id"]) #expect(memory.value.tombstones.count == 1) #expect(await next.revokes().isEmpty) } @Test @MainActor func distinctBackendWithSameRuntimeSourceStillRefusesPayloads() async { let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, old); await push.enable() push.receivedDeviceToken(Data([1]), generation: push.generation) #expect(await pushEventually { memory.value.records.first?.version != nil }) await old.failRevoke(status: 403) await bindPush(push, next, url: "https://other.test/api"); await push.enable() push.receivedDeviceToken(Data([2]), generation: push.generation) #expect(await pushEventually { await next.requests().count == 1 }) push.notificationResponse(pushPayload(), identifier: "ambiguous-server") #expect(!push.consumeRoute()) #expect(!(await push.receiveBackground(pushPayload(kind: "read_state")))) } @Test @MainActor func disableDuringConfigOrPermissionRefreshDoesNotBlockNewRefresh() async { for holdConfig in [true, false] { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api) await api.set(holdConfig: holdConfig); platform.holdPermissionRead = !holdConfig let oldRefresh = Task { await push.refresh() } #expect(await pushEventually { holdConfig ? await api.waitingConfig() : platform.permissionReadWaiter != nil }) await push.disable() if holdConfig { await api.releaseConfig() } else { platform.holdPermissionRead = false; platform.permissionReadWaiter?.resume(returning: .denied) } await oldRefresh.value platform.authorization = .authorized await push.refresh(); await push.enable() #expect(push.permission == .authorized && platform.registrations.count == 1) } } @Test @MainActor func capabilityRecoveryReenrollsSameTokenWithNewInstallation() async throws { let platform = PushPlatform(), memory = PushMemory(), api = PushAPI() let push = SybilAgentNotifications(platform: platform, storage: memory.storage) await bindPush(push, api); await push.enable() let bytes = Data([1, 2, 3]) push.receivedDeviceToken(bytes, generation: push.generation) #expect(await pushEventually { memory.value.records.first?.version != nil }) let oldID = try #require(await api.requests().first?.0) await api.setAvailable(false); await push.refresh() #expect(await api.revokes().contains(oldID)) #expect(memory.value.records.first?.installationID != oldID) await api.setAvailable(true); await push.refresh() push.receivedDeviceToken(bytes, generation: push.generation) #expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil }) #expect(await api.requests().last?.0 != oldID) #expect(push.status == "Notifications are enabled.") } @Test @MainActor func optionalReadStateAndNativeAuthRemainCompatible() throws { let old = try JSONDecoder().decode(AgentReadState.self, from: Data(#"{"cursor":"4","updatedAt":null}"#.utf8)) #expect(old.sourceId == nil) let data = Data(#"{"type":"read_state","sourceId":"11111111-1111-4111-8111-111111111111","cursor":"5","updatedAt":null}"#.utf8) guard case let .readState(read) = try AgentEvent.decode(data) else { Issue.record("Missing read frame"); return } let room = SybilAgentRoom(); try room.applyRead(read); room.flush() #expect(room.snapshot.room.readCursor == "5") #expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "99", sourceId: "foreign")) } room.flush() #expect(room.snapshot.room.readCursor == "5") #expect(room.requiresReload) #expect(try AgentEvent.decode(Data(#"{"type":"read_state","cursor":"-1"}"#.utf8)) == nil) let frame = try JSONDecoder().decode(JSONValue.self, from: Data(APIConfiguration(baseURL: URL(string: "https://example.test")!, authToken: "fixture").agentAuthFrame(after: "0").utf8)) #expect(frame.objectValue?["clientKind"] == .string("native")) #expect(frame.objectValue?["presenceSessionId"] == nil) } }