417 lines
26 KiB
Swift
417 lines
26 KiB
Swift
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<AgentNotificationPermission, Never>?
|
|
var permissionWaiter: CheckedContinuation<Bool, Never>?
|
|
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<Void, Never>?
|
|
var holdConfig = false
|
|
var configWaiter: CheckedContinuation<Void, Never>?
|
|
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<UIBackgroundFetchResult, Never>?
|
|
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)
|
|
}
|
|
}
|