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>
873 lines
30 KiB
Swift
873 lines
30 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import P256K
|
|
import Testing
|
|
|
|
@testable import BuzzPushKit
|
|
|
|
@Suite("Push presentation cache")
|
|
struct BuzzPushPresentationCacheTests {
|
|
private let profileKey = String(repeating: "0", count: 63) + "1"
|
|
private let relayKey = String(repeating: "0", count: 63) + "2"
|
|
private let otherRelayKey = String(repeating: "0", count: 63) + "3"
|
|
|
|
@Test("Verified profile uses display_name, then name, and attaches a bounded local avatar")
|
|
func verifiedProfilePrecedenceAndAvatar() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(
|
|
containerURL: directory,
|
|
now: { Date(timeIntervalSince1970: 1_700_000_100) }
|
|
)
|
|
let event = try signedEvent(
|
|
privateKey: profileKey,
|
|
createdAt: 1_700_000_000,
|
|
kind: 0,
|
|
content:
|
|
#"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"#
|
|
)
|
|
|
|
let needsAvatar = try store.updateProfiles(
|
|
communityID: "community-a",
|
|
relayOrigin: "wss://relay.example/",
|
|
updates: [BuzzPushProfileCacheUpdate(event: event)]
|
|
)
|
|
try store.updateProfiles(
|
|
communityID: "community-b",
|
|
relayOrigin: "wss://relay.example/",
|
|
updates: [BuzzPushProfileCacheUpdate(event: event)]
|
|
)
|
|
|
|
#expect(needsAvatar == Set([event.id]))
|
|
var snapshot = try loadSnapshot(directory)
|
|
var cached = try #require(
|
|
snapshot.profile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: event.pubkey
|
|
)
|
|
)
|
|
#expect(cached.displayName == "Alice Example")
|
|
#expect(cached.avatarPNG == nil)
|
|
#expect(
|
|
snapshot.profile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://other.example",
|
|
pubkey: event.pubkey
|
|
) == nil
|
|
)
|
|
|
|
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01])
|
|
#expect(
|
|
try store.updateAvatar(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
sourceURL: "https://images.example/alice.png",
|
|
avatarPNG: png
|
|
)
|
|
)
|
|
snapshot = try loadSnapshot(directory)
|
|
cached = try #require(
|
|
snapshot.profile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: event.pubkey
|
|
)
|
|
)
|
|
#expect(cached.avatarPNG == png)
|
|
#expect(
|
|
snapshot.profile(
|
|
communityID: "community-b",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: event.pubkey
|
|
)?.avatarPNG == nil
|
|
)
|
|
}
|
|
|
|
@Test("Verified inline raster profile retains its name and accepts a local thumbnail")
|
|
func verifiedInlineRasterProfileAndAvatar() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000)
|
|
let content = try #require(
|
|
String(
|
|
data: JSONSerialization.data(withJSONObject: [
|
|
"display_name": "Fizz",
|
|
"picture": picture,
|
|
]),
|
|
encoding: .utf8
|
|
)
|
|
)
|
|
let event = try signedEvent(privateKey: profileKey, kind: 0, content: content)
|
|
|
|
let needsAvatar = try store.updateProfiles(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
updates: [BuzzPushProfileCacheUpdate(event: event)]
|
|
)
|
|
#expect(needsAvatar == Set([event.id]))
|
|
#expect(
|
|
try loadSnapshot(directory).profile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: event.pubkey
|
|
)?.displayName == "Fizz"
|
|
)
|
|
|
|
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
#expect(
|
|
try store.updateAvatar(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
sourceURL: picture,
|
|
avatarPNG: png
|
|
)
|
|
)
|
|
#expect(
|
|
try loadSnapshot(directory).profile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: event.pubkey
|
|
)?.avatarPNG == png
|
|
)
|
|
}
|
|
|
|
@Test("Verified profile falls back from blank display_name to name")
|
|
func verifiedProfileNameFallback() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let event = try signedEvent(
|
|
privateKey: profileKey,
|
|
kind: 0,
|
|
content: #"{"display_name":" ","name":"Alice"}"#
|
|
)
|
|
|
|
try store.updateProfiles(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
updates: [BuzzPushProfileCacheUpdate(event: event)]
|
|
)
|
|
|
|
let cached = try #require(
|
|
try loadSnapshot(directory).profile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: event.pubkey
|
|
)
|
|
)
|
|
#expect(cached.displayName == "Alice")
|
|
}
|
|
|
|
@Test("Malformed verified profile clears presentation while an unverified event is ignored")
|
|
func malformedAndUnverifiedProfileFallback() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let named = try signedEvent(
|
|
privateKey: profileKey,
|
|
createdAt: 100,
|
|
kind: 0,
|
|
content: #"{"name":"Alice"}"#
|
|
)
|
|
try store.updateProfiles(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
updates: [BuzzPushProfileCacheUpdate(event: named)]
|
|
)
|
|
let malformed = try signedEvent(
|
|
privateKey: profileKey,
|
|
createdAt: 101,
|
|
kind: 0,
|
|
content: "not-json"
|
|
)
|
|
try store.updateProfiles(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
updates: [BuzzPushProfileCacheUpdate(event: malformed)]
|
|
)
|
|
let tampered = VerifiedNostrEvent(
|
|
id: malformed.id,
|
|
pubkey: malformed.pubkey,
|
|
createdAt: 102,
|
|
kind: 0,
|
|
tags: [],
|
|
content: #"{"display_name":"Mallory"}"#,
|
|
sig: malformed.sig
|
|
)
|
|
try store.updateProfiles(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
updates: [BuzzPushProfileCacheUpdate(event: tampered)]
|
|
)
|
|
|
|
let cached = try #require(
|
|
try loadSnapshot(directory).profile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: malformed.pubkey
|
|
)
|
|
)
|
|
#expect(cached.eventID == malformed.id)
|
|
#expect(cached.displayName == nil)
|
|
}
|
|
|
|
@Test("Channel name requires the expected relay signer and accepts opaque IDs")
|
|
func channelAuthorityAndOpaqueID() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let relayPubkey = try pubkey(for: relayKey)
|
|
let opaqueChannelID = "channel/general:v5"
|
|
let verified = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 100,
|
|
kind: 39_000,
|
|
tags: [["d", opaqueChannelID], ["name", " General Chat "], ["t", "stream"]]
|
|
)
|
|
let wrongSigner = try signedEvent(
|
|
privateKey: otherRelayKey,
|
|
createdAt: 101,
|
|
kind: 39_000,
|
|
tags: [["d", opaqueChannelID], ["name", "Impostor"]]
|
|
)
|
|
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "wss://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [wrongSigner, verified],
|
|
membershipEvents: []
|
|
)
|
|
|
|
let cached = try #require(
|
|
try loadSnapshot(directory).channel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: opaqueChannelID
|
|
)
|
|
)
|
|
#expect(cached.eventID == verified.id)
|
|
#expect(cached.displayName == "General Chat")
|
|
#expect(cached.channelType == "stream")
|
|
#expect(cached.relayMetadataPubkey == relayPubkey)
|
|
}
|
|
|
|
@Test("Channel membership requires the same relay authority and keeps exact scoped digests")
|
|
func channelMembershipAuthorityAndOrdering() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let relayPubkey = try pubkey(for: relayKey)
|
|
let firstMember = try pubkey(for: profileKey)
|
|
let secondMember = try pubkey(for: otherRelayKey)
|
|
let metadata = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 100,
|
|
kind: 39_000,
|
|
tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]]
|
|
)
|
|
let newestMembership = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 102,
|
|
kind: 39_002,
|
|
tags: [
|
|
["d", "opaque-channel"],
|
|
["p", firstMember],
|
|
["p", secondMember],
|
|
["p", secondMember],
|
|
]
|
|
)
|
|
let olderMembership = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 101,
|
|
kind: 39_002,
|
|
tags: [["d", "opaque-channel"], ["p", firstMember]]
|
|
)
|
|
let wrongSigner = try signedEvent(
|
|
privateKey: otherRelayKey,
|
|
createdAt: 103,
|
|
kind: 39_002,
|
|
tags: [["d", "opaque-channel"], ["p", firstMember]]
|
|
)
|
|
let malformed = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 104,
|
|
kind: 39_002,
|
|
tags: [["d", "opaque-channel"], ["p", "not-a-pubkey"]]
|
|
)
|
|
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [metadata],
|
|
membershipEvents: [wrongSigner, malformed, newestMembership, olderMembership]
|
|
)
|
|
|
|
let cached = try #require(
|
|
try loadSnapshot(directory).channel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "opaque-channel"
|
|
)
|
|
)
|
|
#expect(cached.memberCount == 2)
|
|
#expect(cached.membershipEventID == newestMembership.id)
|
|
#expect(
|
|
cached.memberDigests
|
|
== [firstMember, secondMember].map {
|
|
BuzzPushPresentationIdentity.channelMember(
|
|
communityID: "community-a",
|
|
channelID: "opaque-channel",
|
|
pubkey: $0
|
|
)
|
|
}.sorted()
|
|
)
|
|
}
|
|
|
|
@Test("Retrying a partial batch prefix preserves newer state and original scope")
|
|
func partialBatchPrefixReplayIsIdempotent() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(
|
|
containerURL: directory,
|
|
now: { Date(timeIntervalSince1970: 1_700_000_100) }
|
|
)
|
|
let relayPubkey = try pubkey(for: relayKey)
|
|
let member = try pubkey(for: profileKey)
|
|
let addedMember = try pubkey(for: otherRelayKey)
|
|
let profiles = try [profileKey, otherRelayKey].map { key in
|
|
try signedEvent(privateKey: key, createdAt: 100, kind: 0, content: #"{"name":"Original"}"#)
|
|
}
|
|
let metadata = try ["prefix", "suffix"].map { channel in
|
|
try signedEvent(
|
|
privateKey: relayKey, createdAt: 100, kind: 39_000,
|
|
tags: [["d", channel], ["name", "Original"], ["t", "stream"]]
|
|
)
|
|
}
|
|
let memberships = try ["prefix", "suffix"].map { channel in
|
|
try signedEvent(
|
|
privateKey: relayKey, createdAt: 100, kind: 39_002,
|
|
tags: [["d", channel], ["p", member]]
|
|
)
|
|
}
|
|
func deliver(
|
|
_ index: Int, community: String = "original", origin: String = "https://relay.example"
|
|
) throws {
|
|
try store.updateProfiles(
|
|
communityID: community, relayOrigin: origin,
|
|
updates: [BuzzPushProfileCacheUpdate(event: profiles[index])]
|
|
)
|
|
try store.updateChannels(
|
|
communityID: community, relayOrigin: origin, relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [metadata[index]], membershipEvents: [memberships[index]]
|
|
)
|
|
}
|
|
|
|
// Native storage accepts the prefix, but the caller loses its acknowledgement.
|
|
try deliver(0)
|
|
try deliver(0, community: "other-community")
|
|
try deliver(0, origin: "https://other-relay.example")
|
|
let beforeRetry = try loadSnapshot(directory)
|
|
let untouchedProfiles = beforeRetry.profiles.filter {
|
|
$0.communityID != "original" || $0.relayOrigin != "https://relay.example"
|
|
}
|
|
let untouchedChannels = beforeRetry.channels.filter {
|
|
$0.communityID != "original" || $0.relayOrigin != "https://relay.example"
|
|
}
|
|
let newerProfile = try signedEvent(
|
|
privateKey: profileKey, createdAt: 101, kind: 0, content: #"{"name":"Newest"}"#
|
|
)
|
|
let newerMetadata = try signedEvent(
|
|
privateKey: relayKey, createdAt: 101, kind: 39_000,
|
|
tags: [["d", "prefix"], ["name", "Newest"], ["t", "forum"]]
|
|
)
|
|
let newerMembership = try signedEvent(
|
|
privateKey: relayKey, createdAt: 101, kind: 39_002,
|
|
tags: [["d", "prefix"], ["p", member], ["p", addedMember]]
|
|
)
|
|
try store.updateProfiles(
|
|
communityID: "original", relayOrigin: "https://relay.example",
|
|
updates: [BuzzPushProfileCacheUpdate(event: newerProfile)]
|
|
)
|
|
try store.updateChannels(
|
|
communityID: "original", relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [newerMetadata], membershipEvents: [newerMembership]
|
|
)
|
|
|
|
// Replay exact events from the accepted prefix, then deliver the missing suffix.
|
|
try deliver(0)
|
|
try deliver(1)
|
|
let completed = try loadSnapshot(directory)
|
|
try deliver(0)
|
|
try deliver(1)
|
|
let replayed = try loadSnapshot(directory)
|
|
#expect(replayed.profiles == completed.profiles)
|
|
#expect(replayed.channels == completed.channels)
|
|
#expect(replayed.profiles.count == 4)
|
|
#expect(replayed.channels.count == 4)
|
|
#expect(
|
|
replayed.profiles.filter {
|
|
$0.communityID != "original" || $0.relayOrigin != "https://relay.example"
|
|
} == untouchedProfiles)
|
|
#expect(
|
|
replayed.channels.filter {
|
|
$0.communityID != "original" || $0.relayOrigin != "https://relay.example"
|
|
} == untouchedChannels)
|
|
let profile = try #require(
|
|
replayed.profile(
|
|
communityID: "original", relayOrigin: "https://relay.example", pubkey: member
|
|
))
|
|
#expect(profile.eventID == newerProfile.id)
|
|
#expect(profile.displayName == "Newest")
|
|
let channel = try #require(
|
|
replayed.channel(
|
|
communityID: "original", relayOrigin: "https://relay.example", channelID: "prefix"
|
|
))
|
|
#expect(channel.eventID == newerMetadata.id)
|
|
#expect(channel.displayName == "Newest")
|
|
#expect(channel.channelType == "forum")
|
|
#expect(channel.membershipEventID == newerMembership.id)
|
|
#expect(channel.memberCount == 2)
|
|
#expect(
|
|
channel.memberDigests
|
|
== [member, addedMember].map {
|
|
BuzzPushPresentationIdentity.channelMember(
|
|
communityID: "original", channelID: "prefix", pubkey: $0
|
|
)
|
|
}.sorted())
|
|
#expect(
|
|
replayed.profile(
|
|
communityID: "original", relayOrigin: "https://relay.example", pubkey: addedMember
|
|
)?.eventID == profiles[1].id)
|
|
let suffix = try #require(
|
|
replayed.channel(
|
|
communityID: "original", relayOrigin: "https://relay.example", channelID: "suffix"
|
|
))
|
|
#expect(suffix.eventID == metadata[1].id)
|
|
#expect(suffix.membershipEventID == memberships[1].id)
|
|
}
|
|
|
|
@Test("Channel authority rotation clears membership signed by the old authority")
|
|
func channelAuthorityRotationClearsMembership() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let relayPubkey = try pubkey(for: relayKey)
|
|
let rotatedRelayPubkey = try pubkey(for: otherRelayKey)
|
|
let member = try pubkey(for: profileKey)
|
|
let metadata = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 100,
|
|
kind: 39_000,
|
|
tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]]
|
|
)
|
|
let membership = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 101,
|
|
kind: 39_002,
|
|
tags: [["d", "opaque-channel"], ["p", member]]
|
|
)
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [metadata],
|
|
membershipEvents: [membership]
|
|
)
|
|
|
|
let rotatedMetadata = try signedEvent(
|
|
privateKey: otherRelayKey,
|
|
createdAt: 50,
|
|
kind: 39_000,
|
|
tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]]
|
|
)
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: rotatedRelayPubkey,
|
|
metadataEvents: [rotatedMetadata],
|
|
membershipEvents: []
|
|
)
|
|
|
|
let cached = try #require(
|
|
try loadSnapshot(directory).channel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "opaque-channel"
|
|
)
|
|
)
|
|
#expect(cached.relayMetadataPubkey == rotatedRelayPubkey)
|
|
#expect(cached.eventID == rotatedMetadata.id)
|
|
#expect(cached.memberCount == nil)
|
|
#expect(cached.memberDigests == nil)
|
|
#expect(cached.membershipEventID == nil)
|
|
}
|
|
|
|
@Test("Oversized channel batches are ignored before cache mutation")
|
|
func oversizedChannelBatchIsIgnored() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let relayPubkey = try pubkey(for: relayKey)
|
|
let initial = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 100,
|
|
kind: 39_000,
|
|
tags: [["d", "opaque-channel"], ["name", "Initial"], ["t", "stream"]]
|
|
)
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [initial],
|
|
membershipEvents: []
|
|
)
|
|
let replacement = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 101,
|
|
kind: 39_000,
|
|
tags: [["d", "opaque-channel"], ["name", "Replacement"], ["t", "stream"]]
|
|
)
|
|
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: Array(
|
|
repeating: replacement,
|
|
count: BuzzPushPresentationCacheStore.maximumChannels + 1
|
|
),
|
|
membershipEvents: []
|
|
)
|
|
|
|
let cached = try #require(
|
|
try loadSnapshot(directory).channel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "opaque-channel"
|
|
)
|
|
)
|
|
#expect(cached.eventID == initial.id)
|
|
#expect(cached.displayName == "Initial")
|
|
}
|
|
|
|
@Test("Global member-digest bound drops oldest complete rosters first")
|
|
func globalMembershipDigestBound() throws {
|
|
let membersPerChannel = BuzzPushPresentationCacheStore.maximumMembersPerChannel
|
|
let channelCount =
|
|
BuzzPushPresentationCacheStore.maximumTotalMemberDigests / membersPerChannel + 1
|
|
let digests = (0..<membersPerChannel).map { String(format: "%064x", $0 + 1) }
|
|
let snapshot = BuzzPushPresentationCacheSnapshot(
|
|
channels: (0..<channelCount).map { index in
|
|
BuzzPushCachedChannel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "channel-\(index)",
|
|
relayMetadataPubkey: String(repeating: "a", count: 64),
|
|
displayName: "Channel \(index)",
|
|
channelType: "stream",
|
|
memberCount: membersPerChannel,
|
|
memberDigests: digests,
|
|
membershipEventID: "membership-\(index)",
|
|
membershipEventCreatedAt: index,
|
|
membershipCachedAt: 1_000 + index,
|
|
eventID: "metadata-\(index)",
|
|
eventCreatedAt: index,
|
|
cachedAt: 1_000 + index
|
|
)
|
|
}
|
|
)
|
|
|
|
let encoded = try BuzzPushPresentationCacheStore.encodedBoundedSnapshot(snapshot)
|
|
let decoded = try JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: encoded)
|
|
let totalDigests = decoded.channels.reduce(0) { $0 + ($1.memberDigests?.count ?? 0) }
|
|
|
|
#expect(totalDigests == BuzzPushPresentationCacheStore.maximumTotalMemberDigests)
|
|
#expect(decoded.channels.first { $0.channelID == "channel-0" }?.memberDigests == nil)
|
|
#expect(
|
|
decoded.channels.first { $0.channelID == "channel-\(channelCount - 1)" }?.memberDigests != nil
|
|
)
|
|
}
|
|
|
|
@Test("Oversized membership keeps provenance but omits an incomplete digest set")
|
|
func oversizedMembershipFallback() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let relayPubkey = try pubkey(for: relayKey)
|
|
let metadata = try signedEvent(
|
|
privateKey: relayKey,
|
|
kind: 39_000,
|
|
tags: [["d", "large-channel"], ["name", "Large"], ["t", "stream"]]
|
|
)
|
|
let memberCount = BuzzPushPresentationCacheStore.maximumMembersPerChannel + 25
|
|
let membership = try signedEvent(
|
|
privateKey: relayKey,
|
|
kind: 39_002,
|
|
tags: [["d", "large-channel"]]
|
|
+ (0..<memberCount).map {
|
|
["p", String(format: "%064x", $0 + 1)]
|
|
}
|
|
)
|
|
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [metadata],
|
|
membershipEvents: [membership]
|
|
)
|
|
|
|
let cached = try #require(
|
|
try loadSnapshot(directory).channel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "large-channel"
|
|
)
|
|
)
|
|
#expect(
|
|
cached.memberCount == BuzzPushPresentationCacheStore.maximumMembersPerChannel + 1
|
|
)
|
|
#expect(cached.memberDigests == nil)
|
|
#expect(cached.membershipEventID == membership.id)
|
|
}
|
|
|
|
@Test("Missing or malformed channel metadata never fabricates a name")
|
|
func malformedChannelMetadataFallback() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let relayPubkey = try pubkey(for: relayKey)
|
|
let blankName = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 100,
|
|
kind: 39_000,
|
|
tags: [["d", "opaque-channel"], ["name", " \n "]]
|
|
)
|
|
let missingChannelID = try signedEvent(
|
|
privateKey: relayKey,
|
|
createdAt: 101,
|
|
kind: 39_000,
|
|
tags: [["name", "Must not be used"]]
|
|
)
|
|
|
|
try store.updateChannels(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: relayPubkey,
|
|
metadataEvents: [blankName, missingChannelID],
|
|
membershipEvents: []
|
|
)
|
|
|
|
let snapshot = try loadSnapshot(directory)
|
|
let cached = try #require(
|
|
snapshot.channel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "opaque-channel"
|
|
)
|
|
)
|
|
#expect(cached.displayName == nil)
|
|
#expect(snapshot.channels.count == 1)
|
|
}
|
|
|
|
@Test("Account sync discards old presentation exports even for a retained account")
|
|
func discardLegacyExports() throws {
|
|
let directory = try temporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let store = BuzzPushPresentationCacheStore(containerURL: directory)
|
|
let profile = try signedEvent(privateKey: profileKey, kind: 0, content: #"{"name":"A"}"#)
|
|
let channel = try signedEvent(
|
|
privateKey: relayKey,
|
|
kind: 39_000,
|
|
tags: [["d", "opaque"], ["name", "General"]]
|
|
)
|
|
try store.updateProfiles(
|
|
communityID: "retained",
|
|
relayOrigin: "https://relay.example",
|
|
updates: [BuzzPushProfileCacheUpdate(event: profile)]
|
|
)
|
|
try store.updateChannels(
|
|
communityID: "retained",
|
|
relayOrigin: "https://relay.example",
|
|
relayMetadataPubkey: try pubkey(for: relayKey),
|
|
metadataEvents: [channel],
|
|
membershipEvents: []
|
|
)
|
|
|
|
try store.replaceCommunities([
|
|
PushLeaseCommunity(
|
|
id: "retained",
|
|
name: "Retained",
|
|
relayUrl: "https://relay.example",
|
|
pubkey: nil,
|
|
policies: []
|
|
)
|
|
])
|
|
|
|
let snapshot = try loadSnapshot(directory)
|
|
#expect(snapshot.communities.map(\.id) == ["retained"])
|
|
#expect(snapshot.profiles.isEmpty)
|
|
#expect(snapshot.channels.isEmpty)
|
|
}
|
|
|
|
@Test("Cache deterministically evicts entries beyond its global bounds")
|
|
func boundedEviction() {
|
|
var snapshot = BuzzPushPresentationCacheSnapshot(
|
|
profiles: (0...BuzzPushPresentationCacheStore.maximumProfiles).map { index in
|
|
BuzzPushCachedProfile(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: String(format: "%064x", index),
|
|
displayName: "Profile \(index)",
|
|
pictureHash: nil,
|
|
avatarPNG: nil,
|
|
eventID: String(format: "%064x", index),
|
|
eventCreatedAt: index,
|
|
cachedAt: index
|
|
)
|
|
},
|
|
channels: (0...BuzzPushPresentationCacheStore.maximumChannels).map { index in
|
|
BuzzPushCachedChannel(
|
|
communityID: "community-a",
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "channel-\(index)",
|
|
relayMetadataPubkey: String(repeating: "a", count: 64),
|
|
displayName: "Channel \(index)",
|
|
eventID: String(format: "%064x", index),
|
|
eventCreatedAt: index,
|
|
cachedAt: index
|
|
)
|
|
}
|
|
)
|
|
|
|
BuzzPushPresentationCacheStore.enforceBounds(&snapshot)
|
|
|
|
#expect(snapshot.profiles.count == BuzzPushPresentationCacheStore.maximumProfiles)
|
|
#expect(snapshot.channels.count == BuzzPushPresentationCacheStore.maximumChannels)
|
|
#expect(snapshot.profiles.contains { $0.cachedAt == 0 } == false)
|
|
#expect(snapshot.channels.contains { $0.cachedAt == 0 } == false)
|
|
}
|
|
|
|
@Test("Encoded cache remains bounded with adversarial strings and maximum avatars")
|
|
func encodedCacheByteBound() throws {
|
|
let controlText = String(repeating: "\u{0001}", count: 1_024)
|
|
let avatar =
|
|
Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
+ Data(
|
|
repeating: 0,
|
|
count: BuzzPushPresentationCacheStore.maximumAvatarBytes - 8
|
|
)
|
|
let snapshot = BuzzPushPresentationCacheSnapshot(
|
|
profiles: (0..<80).map { index in
|
|
BuzzPushCachedProfile(
|
|
communityID: controlText,
|
|
relayOrigin: "https://relay.example",
|
|
pubkey: String(format: "%064x", index),
|
|
displayName: controlText,
|
|
pictureHash: String(repeating: "a", count: 64),
|
|
avatarPNG: avatar,
|
|
eventID: String(format: "%064x", index),
|
|
eventCreatedAt: index,
|
|
cachedAt: index
|
|
)
|
|
},
|
|
channels: (0..<BuzzPushPresentationCacheStore.maximumChannels).map { index in
|
|
BuzzPushCachedChannel(
|
|
communityID: controlText,
|
|
relayOrigin: "https://relay.example",
|
|
channelID: "\(controlText)\(index)",
|
|
relayMetadataPubkey: String(repeating: "b", count: 64),
|
|
displayName: controlText,
|
|
eventID: String(format: "%064x", index),
|
|
eventCreatedAt: index,
|
|
cachedAt: 1_000 + index
|
|
)
|
|
}
|
|
)
|
|
|
|
let encoded = try BuzzPushPresentationCacheStore.encodedBoundedSnapshot(snapshot)
|
|
let decoded = try JSONDecoder().decode(
|
|
BuzzPushPresentationCacheSnapshot.self,
|
|
from: encoded
|
|
)
|
|
|
|
#expect(encoded.count <= BuzzPushPresentationCacheStore.maximumSnapshotBytes)
|
|
#expect(decoded.channels.first?.cachedAt == 1_511)
|
|
#expect(decoded.profiles.count + decoded.channels.count < 592)
|
|
}
|
|
|
|
@Test("Display-name normalization has character and UTF-8 bounds")
|
|
func displayNameUTF8Bound() throws {
|
|
let oneOversizedGrapheme = "a" + String(repeating: "\u{0301}", count: 2_048)
|
|
let event = try signedEvent(
|
|
privateKey: profileKey,
|
|
kind: 0,
|
|
content: try String(
|
|
data: JSONSerialization.data(withJSONObject: ["display_name": oneOversizedGrapheme]),
|
|
encoding: .utf8
|
|
) ?? ""
|
|
)
|
|
|
|
let metadata = BuzzPushPresentationCacheStore.profileMetadata(event)
|
|
|
|
#expect(metadata.displayName == nil || metadata.displayName!.utf8.count <= 512)
|
|
}
|
|
|
|
private func temporaryDirectory() throws -> URL {
|
|
let url = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("buzz-push-cache-\(UUID().uuidString)", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
return url
|
|
}
|
|
|
|
private func loadSnapshot(_ directory: URL) throws -> BuzzPushPresentationCacheSnapshot {
|
|
let data = try Data(
|
|
contentsOf: directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)
|
|
)
|
|
return try JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data)
|
|
}
|
|
|
|
private func pubkey(for privateKey: String) throws -> String {
|
|
let bytes = try #require(VerifiedNostrEvent.hexBytes(privateKey))
|
|
let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes)
|
|
return VerifiedNostrEvent.hex(key.xonly.bytes)
|
|
}
|
|
|
|
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)
|
|
)
|
|
}
|
|
}
|