feat(agent): add opt-in push notifications

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent 80748f764f
commit a1c0264d36
51 changed files with 4815 additions and 121 deletions
@@ -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)