feat(agent): add opt-in push notifications
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private final class PushURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, Data))?
|
||||
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, Data)) { lock.withLock { handler = value } }
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let handler = Self.lock.withLock { Self.handler! }
|
||||
let (status, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Location": "https://untrusted.test/"])!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data); client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
private func pushClient(_ url: String = "https://example.test/api", token: String? = "fixture-token") -> SybilAPIClient {
|
||||
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [PushURLProtocol.self]
|
||||
return SybilAPIClient(configuration: .init(baseURL: URL(string: url)!, authToken: token), session: URLSession(configuration: config))
|
||||
}
|
||||
private func pushRequestBody(_ request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open(); defer { stream.close() }
|
||||
var bytes = Data(), buffer = [UInt8](repeating: 0, count: 1024)
|
||||
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
|
||||
return bytes
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentPushAPITests {
|
||||
@Test func authenticatedBasePathRegistrationAndRevoke() async throws {
|
||||
let id = UUID()
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.host == "example.test")
|
||||
#expect(request.url?.query == nil)
|
||||
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
|
||||
#expect(request.value(forHTTPHeaderField: "Cache-Control") == "no-store")
|
||||
#expect(request.timeoutInterval == 8)
|
||||
if request.httpMethod == "GET" {
|
||||
#expect(request.url?.path == "/api/v1/agent/push/config")
|
||||
return (200, Data(#"{"version":1,"enabled":true,"apnsAvailable":true,"webPushAvailable":false,"sourceId":"11111111-1111-4111-8111-111111111111","quarantined":false,"policy":{"future":1}}"#.utf8))
|
||||
}
|
||||
#expect(request.url?.path == "/api/v1/agent/push/installations/" + id.uuidString.lowercased())
|
||||
if request.httpMethod == "DELETE" { return (204, Data()) }
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["transport", "enabled", "token", "environment", "topic"]))
|
||||
#expect(object["token"] as? String == "00010aff")
|
||||
#expect(object["environment"] as? String == "production")
|
||||
#expect(object["topic"] as? String == "net.buzzert.sybil2")
|
||||
return (200, try JSONEncoder().encode(AgentPushReceipt(installationId: id.uuidString, transport: "apns", enabled: true, version: 7)))
|
||||
}
|
||||
let client = pushClient()
|
||||
#expect(try await client.agentPushConfig().available)
|
||||
#expect(try await client.agentRegisterPush(id: id, registration: .init(token: "00010aff", environment: .production)).version == 7)
|
||||
try await client.agentRevokePush(id: id)
|
||||
}
|
||||
|
||||
@Test func unsupportedRedirectAndReflectedSecretsAreNotExposed() async {
|
||||
for status in [302, 401, 404, 409, 503] {
|
||||
PushURLProtocol.install { _ in (status, Data(#"{"token":"do-not-reflect-this-token","message":"do-not-reflect-this-token"}"#.utf8)) }
|
||||
do { _ = try await pushClient().agentPushConfig(); Issue.record("Unexpected success") }
|
||||
catch { #expect(!error.localizedDescription.contains("do-not-reflect")) }
|
||||
}
|
||||
PushURLProtocol.install { _ in Issue.record("Unsafe request reached transport"); return (200, Data()) }
|
||||
for client in [pushClient(token: nil), pushClient("https://example.test/api?token=bad"), pushClient("https://user:password@example.test")] {
|
||||
do { _ = try await client.agentPushConfig(); Issue.record("Unsafe configuration accepted") }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sourceAwareReadWritesAndLegacyFallback() async throws {
|
||||
let source = "11111111-1111-4111-8111-111111111111"
|
||||
for expectedSource: String? in [nil, source] {
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.path == "/api/v1/agent/read-state")
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: String])
|
||||
#expect(object["cursor"] == "4" && object["sourceId"] == expectedSource)
|
||||
return (200, try JSONEncoder().encode(AgentReadState(cursor: "4", sourceId: expectedSource)))
|
||||
}
|
||||
#expect(try await pushClient().agentMarkRead(cursor: "4", sourceID: expectedSource).sourceId == expectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
var requests: [String] = []
|
||||
var submissions: [(String, String)] = []
|
||||
var read = "0"
|
||||
var readSource: String?
|
||||
var readWrites: [(cursor: String, source: String?)] = []
|
||||
var writeReply: AgentReadState?
|
||||
var writeConflict: Bool?
|
||||
var holdWrite = false
|
||||
var heldWrite: CheckedContinuation<Void, Never>?
|
||||
var failRead = false
|
||||
var failSubmit = false
|
||||
var malformedReceipt = false
|
||||
@@ -56,16 +62,34 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
}
|
||||
func agentReadState() async -> AgentReadState {
|
||||
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
|
||||
return AgentReadState(cursor: read)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func pauseRead() { holdRead = true }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read, sourceId: readSource)); heldRead = nil }
|
||||
func hasHeldRead() -> Bool { heldRead != nil }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState {
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read)
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await agentMarkRead(cursor: cursor, sourceID: nil)
|
||||
}
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
readWrites.append((cursor, sourceID))
|
||||
if holdWrite { await withCheckedContinuation { heldWrite = $0 } }
|
||||
if let coded = writeConflict {
|
||||
if coded { throw AgentAttachmentIssue(message: "Source mismatch", code: "source_mismatch", statusCode: 409) }
|
||||
throw APIError.httpError(statusCode: 409, message: "Source mismatch")
|
||||
}
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
if let writeReply { return writeReply }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func setReadState(_ state: AgentReadState) { read = state.cursor; readSource = state.sourceId }
|
||||
func setWriteReply(_ state: AgentReadState?) { writeReply = state }
|
||||
func setWriteConflict(coded: Bool) { writeConflict = coded }
|
||||
func pauseWrite() { holdWrite = true }
|
||||
func releaseWrite() { holdWrite = false; heldWrite?.resume(); heldWrite = nil }
|
||||
func hasHeldWrite() -> Bool { heldWrite != nil }
|
||||
func writtenReads() -> [(cursor: String, source: String?)] { readWrites }
|
||||
func setHistory(_ pages: [String: AgentPage]) { self.pages = pages }
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append((content, requestID))
|
||||
@@ -420,6 +444,89 @@ struct AgentTests {
|
||||
#expect(await eventually { room.connection == "Connecting" })
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func legacyReadPromotionCannotRelabelQueuedHistoryAndRequiresFreshReload() async throws {
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90"))
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; await client.pauseWrite(); room.markRead(renderedCursor: "100")
|
||||
for _ in 0..<100 {
|
||||
if await client.hasHeldWrite() { break }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
#expect(await client.hasHeldWrite())
|
||||
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "0", sourceId: sourceB)) }
|
||||
#expect(room.requiresReload && room.connection == "Reload required")
|
||||
#expect(room.snapshot.room.readCursor == "90" && room.snapshot.room.cursor == "100")
|
||||
await client.setWriteReply(.init(cursor: "100", sourceId: sourceB)); await client.releaseWrite()
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
#expect(room.requiresReload && room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == nil)
|
||||
#expect(room.messages.map(\.id) == ["old"])
|
||||
|
||||
// Explicit reconnect resets cursor space before discovering B or loading
|
||||
// its history. The old queued100 can never be sent under sourceB.
|
||||
await client.setWriteReply(nil); await client.setReadState(.init(cursor: "0", sourceId: sourceB))
|
||||
await client.setHistory(["0": agentPage([agentMessage("new", entry: "1")], cursor: "1")])
|
||||
room.retryConnection()
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(!room.requiresReload && room.snapshot.room.readCursor == "0")
|
||||
#expect(room.messages.map(\.id) == ["new"])
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "1" })
|
||||
let writes = await client.writtenReads()
|
||||
#expect(writes.count == 2 && writes.last?.cursor == "1" && writes.last?.source == sourceB)
|
||||
#expect(!writes.contains { $0.cursor == "100" && $0.source == sourceB })
|
||||
}
|
||||
|
||||
@Test @MainActor func historyBeforeReadPinsLegacySourceBeforePublishing() throws {
|
||||
let room = SybilAgentRoom(); defer { room.stop() }
|
||||
try room.receive(.messages(agentPage([agentMessage("legacy", entry: "100")], cursor: "100")))
|
||||
#expect(throws: AgentFailure.self) {
|
||||
try room.applyRead(.init(cursor: "0", sourceId: "22222222-2222-4222-8222-222222222222"))
|
||||
}
|
||||
#expect(room.requiresReload && room.snapshot.room.cursor == "100" && room.snapshot.room.readCursor == "0")
|
||||
}
|
||||
|
||||
@Test @MainActor func mismatchedAndNonAdvancingReadAcknowledgmentsStopWriter() async throws {
|
||||
let sourceA = "11111111-1111-4111-8111-111111111111"
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
for reply in [AgentReadState(cursor: "100", sourceId: sourceB), .init(cursor: "90", sourceId: sourceA), .init(cursor: "100"), .init(cursor: "invalid", sourceId: sourceA)] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: sourceA)); await client.setWriteReply(reply)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == sourceA)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func readSourceConflict409StopsWriterUntilReload() async {
|
||||
for coded in [false, true] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: "11111111-1111-4111-8111-111111111111"))
|
||||
await client.setWriteConflict(coded: coded)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100")
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
|
||||
let client = TestAgentClient(), memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
|
||||
Reference in New Issue
Block a user