mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
A notification can arrive from someone whose profile the main app has never loaded. The iOS notification extension now fetches and caches sender profiles, channel details, and avatars itself. The main app supplies account configuration and Keychain credentials only. After resolving the message, the extension allows 1.5 seconds for sender and channel details. It uses available stale data when a refresh is slow or fails, delivers once, and lets late results update its private cache. Profiles, channel details, and avatar refresh checks share a one-hour freshness threshold. A verified avatar removal clears the previous image. Account removal prevents further use of that sign-in's cached data. Profile, channel metadata, and membership use separate bounded downloads, so an oversized roster cannot discard the smaller records. Sender enrichment can finish while channel requests are pending. A successful partial channel refresh atomically removes its old counterpart, preventing a mixed group identity or recipient count. Group-specific notification presentation still requires a verified recipient count. The [specification](https://github.com/block/buzz/blob/native-notification-enrichment/docs/native-notification-enrichment.md) describes the display, caching, and account-removal behavior. General persistent image caching in Flutter remains separate. ### Validation [Hosted CI](https://github.com/block/buzz/actions/runs/38780108487) passed on the final commit, including all Flutter shards, Android, the full iOS release and simulator builds, and native controls/push-navigation tests. The [current-commit security review](https://github.com/block/buzz/pull/8369#issuecomment-6197508385) found no issues. The native package passed 159 tests, including new coverage for cache freshness, avatar replacement and removal, sign-in isolation, concurrent storage, late results, single delivery, bounded downloads, and SVG validation. A regression test exposed identical HTTP authentication event IDs for requests in the same second; each request now includes a unique nonce, matching the Dart and CLI clients. Full local CI passed before that small authentication fix. The complete native suite and signed device build passed again on the latest commit, including response-size limits, overlapping avatar downloads, canonical relay-origin matching, independent profile/channel refreshes, account-key changes before and during requests, and a single initial snapshot read across 64 accounts, signed IPv6 avatar authorities, and partial channel refreshes. Focused Flutter regression tests passed 198 tests with three configuration-specific skips; seven avatar widget tests also passed. Flutter analysis is clean, and the notification extension builds for the simulator. On an iPhone SE, the notification displayed the sender name, avatar, channel, and message before the updated main app was opened. Repeat notifications used a byte-identical private cache. Three notifications delivered together displayed the avatar after the authentication fix. The next notification also displayed correctly after terminating the extension process. The existing generic-push design can select the same newest unread message for several pushes; this change does not alter message selection.  Authenticated HTTP avatar fetching and persistent HTTP cache reuse also passed on the SE. The first notification downloaded a relay thumbnail. After terminating the extension and expiring its separate avatar entry, the next notification displayed the avatar and refreshed that entry without another media request in the relay logs. The main app remained unopened throughout. The original app build and test profile were restored afterward. Automated tests cover slow and failed requests, stale images, removal, and account revocation; those cases have not all been exercised on the phone. ### Before merge - [x] Verify authenticated HTTP avatar fetching and cache reuse on iOS against a working relay media service. <!-- buzz-review-completed --> --------- Signed-off-by: Tom Brow <tomb@block.xyz>
337 lines
15 KiB
Swift
337 lines
15 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import P256K
|
|
import Testing
|
|
|
|
@testable import BuzzPushKit
|
|
|
|
@Suite("Extension-owned notification cache")
|
|
struct BuzzNotificationCacheTests {
|
|
private let key = String(repeating: "0", count: 63) + "1"
|
|
|
|
private func scope(_ session: String = "login-one") throws -> BuzzNotificationCacheScope {
|
|
try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.example",
|
|
pubkey: "account", policies: [], notificationSessionID: session)))
|
|
}
|
|
|
|
private func directory() throws -> URL {
|
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
return url
|
|
}
|
|
|
|
@Test("Replacement retains the previous avatar; removal rejects a late replacement")
|
|
func replacementAndRemoval() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
let first = try signedEvent(
|
|
privateKey: key, createdAt: 1, kind: 0,
|
|
content: #"{"name":"A","picture":"https://image.example/a.png"}"#)
|
|
try cache.save(event: first, scope: scope)
|
|
let firstRequest = try #require(
|
|
try cache.beginAvatarRequest(
|
|
profileID: first.id, pubkey: first.pubkey, scope: scope))
|
|
let bytes = Data([1, 2, 3])
|
|
try cache.saveAvatar(
|
|
bytes, source: "https://image.example/a.png",
|
|
profileID: first.id, requestID: firstRequest, pubkey: first.pubkey, scope: scope)
|
|
let replacement = try signedEvent(
|
|
privateKey: key, createdAt: 2, kind: 0,
|
|
content: #"{"name":"A","picture":"https://image.example/b.png"}"#)
|
|
try cache.save(event: replacement, scope: scope)
|
|
#expect(try cache.avatar(scope: scope, pubkey: first.pubkey)?.data == bytes)
|
|
let replacementRequest = try #require(
|
|
try cache.beginAvatarRequest(
|
|
profileID: replacement.id, pubkey: first.pubkey, scope: scope))
|
|
let removal = try signedEvent(privateKey: key, createdAt: 3, kind: 0, content: #"{"name":"A"}"#)
|
|
try cache.save(event: removal, scope: scope)
|
|
try cache.saveAvatar(
|
|
bytes, source: "https://image.example/b.png",
|
|
profileID: replacement.id, requestID: replacementRequest, pubkey: first.pubkey, scope: scope)
|
|
#expect(try cache.avatar(scope: scope, pubkey: first.pubkey) == nil)
|
|
try cache.save(event: first, scope: scope)
|
|
#expect(try cache.metadata(scope: scope, kind: 0, author: first.pubkey)?.event.id == removal.id)
|
|
}
|
|
|
|
@Test("Two cache objects preserve each other's records and data survives reopening")
|
|
func multipleInstances() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let first = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
let second = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
let profile = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
|
|
let channel = try signedEvent(
|
|
privateKey: key, kind: 39000, tags: [["d", "room"], ["name", "General"]])
|
|
try first.save(event: profile, scope: scope)
|
|
try second.save(event: channel, scope: scope)
|
|
#expect(
|
|
try first.metadata(scope: scope, kind: 39000, author: channel.pubkey, identifier: "room")?
|
|
.event == channel)
|
|
#expect(try second.metadata(scope: scope, kind: 0, author: profile.pubkey)?.event == profile)
|
|
}
|
|
|
|
@Test("Concurrent notification writers preserve every independent record")
|
|
func concurrentWriters() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let events = try (0..<24).map { index in
|
|
try signedEvent(
|
|
privateKey: key, kind: 39000,
|
|
tags: [["d", "room-\(index)"], ["name", "Room \(index)"]])
|
|
}
|
|
let errorLock = NSLock()
|
|
var failures: [String] = []
|
|
DispatchQueue.concurrentPerform(iterations: events.count) { index in
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
do {
|
|
try cache.save(event: events[index], scope: scope)
|
|
let record = try cache.metadata(
|
|
scope: scope, kind: 39000,
|
|
author: events[index].pubkey, identifier: "room-\(index)")
|
|
if record?.event != events[index] {
|
|
errorLock.lock()
|
|
failures.append("Missing record \(index)")
|
|
errorLock.unlock()
|
|
}
|
|
} catch {
|
|
errorLock.lock()
|
|
failures.append(String(describing: error))
|
|
errorLock.unlock()
|
|
}
|
|
}
|
|
#expect(failures.isEmpty)
|
|
let reopened = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
for (index, event) in events.enumerated() {
|
|
#expect(
|
|
try reopened.metadata(
|
|
scope: scope, kind: 39000,
|
|
author: event.pubkey, identifier: "room-\(index)")?.event == event)
|
|
}
|
|
}
|
|
|
|
@Test("A failed batch cannot publish only its channel metadata")
|
|
func atomicBatchFailure() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
let channel = try signedEvent(
|
|
privateKey: key, createdAt: 1, kind: 39000, tags: [["d", "room"]])
|
|
let members = try signedEvent(
|
|
privateKey: key, createdAt: 1, kind: 39002, tags: [["d", "room"]])
|
|
try cache.save(events: [channel, members], scope: scope)
|
|
let newer = try signedEvent(privateKey: key, createdAt: 2, kind: 39000, tags: [["d", "room"]])
|
|
let invalid = try signedEvent(privateKey: key, createdAt: 2, kind: 9)
|
|
#expect(throws: BuzzNotificationCacheError.self) {
|
|
try cache.save(events: [newer, invalid], scope: scope)
|
|
}
|
|
let snapshot = try cache.presentation(
|
|
scope: scope, sender: channel.pubkey,
|
|
channelID: "room", authority: channel.pubkey)
|
|
#expect(snapshot.channel?.event == channel)
|
|
#expect(snapshot.membership?.event == members)
|
|
}
|
|
|
|
@Test("Partial channel refreshes cannot reuse an old counterpart")
|
|
func partialChannelRefresh() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
for refreshedKind in [39000, 39002] {
|
|
let old = try [39000, 39002].map {
|
|
try signedEvent(privateKey: key, createdAt: 1, kind: $0, tags: [["d", "room-\(refreshedKind)"]])
|
|
}
|
|
try cache.saveChannel(channel: old[0], membership: old[1], scope: scope)
|
|
let newer = try signedEvent(
|
|
privateKey: key, createdAt: 2, kind: refreshedKind,
|
|
tags: [["d", "room-\(refreshedKind)"]])
|
|
try cache.saveChannel(
|
|
channel: refreshedKind == 39000 ? newer : nil,
|
|
membership: refreshedKind == 39002 ? newer : nil, scope: scope)
|
|
let snapshot = try cache.presentation(
|
|
scope: scope, sender: newer.pubkey,
|
|
channelID: "room-\(refreshedKind)", authority: newer.pubkey)
|
|
#expect(snapshot.channel?.event == (refreshedKind == 39000 ? newer : nil))
|
|
#expect(snapshot.membership?.event == (refreshedKind == 39002 ? newer : nil))
|
|
}
|
|
}
|
|
|
|
@Test("Older channel responses cannot invalidate or partly replace a newer pair")
|
|
func olderChannelRefresh() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
let current = try [39000, 39002].map {
|
|
try signedEvent(privateKey: key, createdAt: 2, kind: $0, tags: [["d", "room"]])
|
|
}
|
|
try cache.saveChannel(channel: current[0], membership: current[1], scope: scope)
|
|
let older = try signedEvent(privateKey: key, createdAt: 1, kind: 39000, tags: [["d", "room"]])
|
|
let newer = try signedEvent(privateKey: key, createdAt: 3, kind: 39002, tags: [["d", "room"]])
|
|
try cache.saveChannel(channel: older, membership: nil, scope: scope)
|
|
try cache.saveChannel(channel: older, membership: newer, scope: scope)
|
|
try cache.saveChannel(channel: nil, membership: nil, scope: scope)
|
|
let snapshot = try cache.presentation(
|
|
scope: scope, sender: current[0].pubkey, channelID: "room", authority: current[0].pubkey)
|
|
#expect(snapshot.channel?.event == current[0])
|
|
#expect(snapshot.membership?.event == current[1])
|
|
}
|
|
|
|
@Test("Concurrent channel readers see one complete refresh")
|
|
func atomicBatchReaders() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let pairs = try (1...20).map { version in
|
|
try [39000, 39002].map { kind in
|
|
try signedEvent(privateKey: key, createdAt: version, kind: kind, tags: [["d", "room"]])
|
|
}
|
|
}
|
|
let errorsLock = NSLock()
|
|
var errors: [String] = []
|
|
DispatchQueue.concurrentPerform(iterations: pairs.count) { index in
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
do {
|
|
try cache.save(events: pairs[index], scope: scope)
|
|
let snapshot = try cache.presentation(
|
|
scope: scope, sender: pairs[index][0].pubkey,
|
|
channelID: "room", authority: pairs[index][0].pubkey)
|
|
if snapshot.channel?.event.createdAt != snapshot.membership?.event.createdAt {
|
|
errorsLock.lock()
|
|
errors.append("Mixed channel refresh")
|
|
errorsLock.unlock()
|
|
}
|
|
} catch {
|
|
errorsLock.lock()
|
|
errors.append(String(describing: error))
|
|
errorsLock.unlock()
|
|
}
|
|
}
|
|
#expect(errors.isEmpty)
|
|
}
|
|
|
|
@Test("Reads do not extend freshness; a successful recheck does")
|
|
func freshness() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
|
|
let first = BuzzNotificationCache(
|
|
directory: root,
|
|
now: { Date(timeIntervalSince1970: 100) }, isCurrent: { $0 == scope })
|
|
try first.save(event: event, scope: scope)
|
|
let cached = try #require(try first.metadata(scope: scope, kind: 0, author: event.pubkey))
|
|
#expect(cached.isFresh(at: Date(timeIntervalSince1970: 3699)))
|
|
#expect(!cached.isFresh(at: Date(timeIntervalSince1970: 3700)))
|
|
let next = BuzzNotificationCache(
|
|
directory: root,
|
|
now: { Date(timeIntervalSince1970: 4000) }, isCurrent: { $0 == scope })
|
|
#expect(
|
|
try next.metadata(scope: scope, kind: 0, author: event.pubkey)?.checkedAt == cached.checkedAt)
|
|
try next.save(event: event, scope: scope)
|
|
#expect(
|
|
try next.metadata(scope: scope, kind: 0, author: event.pubkey)?.checkedAt
|
|
== Date(timeIntervalSince1970: 4000))
|
|
}
|
|
|
|
@Test("Removed account is inaccessible and a new sign-in cannot reuse its cache")
|
|
func removedAccount() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let old = try scope()
|
|
let new = try scope("login-two")
|
|
let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
|
|
try BuzzNotificationCache(directory: root, isCurrent: { $0 == old }).save(
|
|
event: event, scope: old)
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == new })
|
|
#expect(throws: BuzzNotificationCacheError.self) {
|
|
try cache.metadata(scope: old, kind: 0, author: event.pubkey)
|
|
}
|
|
#expect(throws: BuzzNotificationCacheError.self) { try cache.save(event: event, scope: old) }
|
|
#expect(try cache.metadata(scope: new, kind: 0, author: event.pubkey) == nil)
|
|
try cache.removeRevokedScopes(keeping: [new])
|
|
#expect(
|
|
!FileManager.default.fileExists(atPath: root.appendingPathComponent(old.directoryName).path))
|
|
}
|
|
|
|
@Test("Incomplete cache file is reported, removed, and can be fetched again")
|
|
func interruptedWriteRecovery() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try scope()
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
let event = try signedEvent(privateKey: key, kind: 0, content: #"{"name":"A"}"#)
|
|
try cache.save(event: event, scope: scope)
|
|
let file = root.appendingPathComponent(scope.directoryName).appendingPathComponent(
|
|
"metadata.json")
|
|
try Data("{broken".utf8).write(to: file)
|
|
#expect(throws: BuzzNotificationCacheError.self) {
|
|
try cache.metadata(scope: scope, kind: 0, author: event.pubkey)
|
|
}
|
|
try cache.save(event: event, scope: scope)
|
|
#expect(try cache.metadata(scope: scope, kind: 0, author: event.pubkey)?.event == event)
|
|
}
|
|
|
|
@Test("Bootstrap keeps a session across updates but renews it after removal")
|
|
func bootstrapSession() throws {
|
|
let root = try directory()
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let bootstrap = BuzzPushPresentationCacheStore(containerURL: root)
|
|
let community = PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.example",
|
|
pubkey: "account", policies: [])
|
|
func session() throws -> String? {
|
|
let data = try Data(
|
|
contentsOf: root.appendingPathComponent(BuzzPushPresentationCacheStore.fileName))
|
|
return BuzzPushPresentationCacheSnapshot.decode(data).communities.first?.notificationSessionID
|
|
}
|
|
try bootstrap.replaceCommunities([community])
|
|
let first = try #require(try session())
|
|
try bootstrap.replaceCommunities([community])
|
|
#expect(try session() == first)
|
|
try bootstrap.replaceCommunities([])
|
|
try bootstrap.replaceCommunities([community])
|
|
#expect(try session() != first)
|
|
}
|
|
|
|
private func signedEvent(
|
|
privateKey: String,
|
|
createdAt: Int = 1_700_000_000,
|
|
kind: Int,
|
|
tags: [[String]] = [],
|
|
content: String = ""
|
|
) throws -> VerifiedNostrEvent {
|
|
let privateKeyBytes = try #require(VerifiedNostrEvent.hexBytes(privateKey))
|
|
let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes)
|
|
let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes)
|
|
let serialization = try VerifiedNostrEvent.canonicalSerialization(
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: kind,
|
|
tags: tags,
|
|
content: content
|
|
)
|
|
let digest = Array(SHA256.hash(data: serialization))
|
|
var message = digest
|
|
var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32)
|
|
let signature = try key.signature(message: &message, auxiliaryRand: &randomness)
|
|
return VerifiedNostrEvent(
|
|
id: VerifiedNostrEvent.hex(digest),
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: kind,
|
|
tags: tags,
|
|
content: content,
|
|
sig: VerifiedNostrEvent.hex(signature.dataRepresentation)
|
|
)
|
|
}
|
|
}
|