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.. 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) ) } }