mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
🤖 ## Summary When an iOS push notification comes from someone the app has no cached name for, the notification title showed the first characters of the sender's raw public key — for example `aa4fc866…`. That fragment is unreadable and doesn't match how the same person appears anywhere else in Buzz. This PR changes that title to the compact form of the sender's npub (npub is the human-readable encoding of a Nostr public key): first 8 and last 4 characters — for example `npub14f8…9nsy`, the same identity shape used across the desktop and mobile apps. - Unnamed senders: raw hex fragment → compact npub. - Named senders: unchanged — a sender the app has a display name for still titles the notification with that name. - Unverifiable sender identities (malformed keys, or lookalike strings that are not literal 64-hex-digit keys) now render a neutral "Someone" instead of partial raw key material. - Everything else about the notification is unchanged: body text, subtitle, thread matching and grouping, deep-link navigation, thread identifiers, and the internal hex public key the resolver matches on. The native iOS notification-service package (`BuzzPushKit`) gains a minimal in-house bech32 codec (bech32 is the checksummed string encoding npubs use) — checksum-validated, 32-byte keys only, and no new external dependency. The hex input branch accepts exactly a 64 ASCII hex digit key before any parsing, so strings that merely parse like hex (for example a run of `+a` pairs) cannot become a displayed identity; this is input validation for presentation. Event signature verification is untouched. ### Related issue Fixes: N/A. Searched existing issues/PRs for push-notification npub identity — closest related: none found. ### Testing At head `3e3f2813b8864b76257ccb50dea3a4b31fa4de0d` (base `44316ff72f5f7de014c66b01cbf534298a70c249`; 4 files, +321/−4): - CI `Mobile Swift` lane, at this exact head — all passed: `swift test` (73 tests, 0 failures), the SwiftPM debug and release builds of `mobile/ios/BuzzPushKit`, and the unsigned iOS release build. - Test coverage: npub encoding cross-checked against independent nostr-rs/NIP-19 vectors; rejection of bad checksums, mixed case, wrong lengths, invalid alphabet, padding, and non-32-byte payloads; resolver boundary matrix — hex/npub/invalid sender keys render compact npub or "Someone" while body, subtitle, sender key, and thread identifier pass through; named senders keep cached display names. ### Task provenance Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9` Task: buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6 --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
886 lines
29 KiB
Swift
886 lines
29 KiB
Swift
import CryptoKit
|
||
import Foundation
|
||
import P256K
|
||
import XCTest
|
||
|
||
@testable import BuzzPushKit
|
||
|
||
#if canImport(FoundationNetworking)
|
||
import FoundationNetworking
|
||
#endif
|
||
|
||
final class BuzzPushNotificationResolverTests: XCTestCase {
|
||
static let privateKey = String(repeating: "0", count: 63) + "1"
|
||
static let ownPubkey =
|
||
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
||
static let now = Int(Date().timeIntervalSince1970)
|
||
static let profilePrivateKey = String(repeating: "0", count: 63) + "2"
|
||
static let relayPrivateKey = String(repeating: "0", count: 63) + "3"
|
||
static let gatewayBody = "Reconnect to your relay now"
|
||
static let channelID = "123e4567-e89b-42d3-a456-426614174000"
|
||
static let unnamedSenderHex =
|
||
"aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4"
|
||
static let unnamedSenderNpub =
|
||
"npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy"
|
||
|
||
override func setUp() {
|
||
super.setUp()
|
||
URLProtocolStub.reset()
|
||
}
|
||
|
||
override func tearDown() {
|
||
URLProtocolStub.reset()
|
||
super.tearDown()
|
||
}
|
||
|
||
func testResolveReturnsNilWhenCommunitiesDataIsMissing() {
|
||
let result = resolve(makeResolver(communitiesData: nil))
|
||
|
||
XCTAssertNil(result)
|
||
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
|
||
}
|
||
|
||
func testResolveReturnsNilWhenCommunitiesDataIsUndecodable() {
|
||
let result = resolve(makeResolver(communitiesData: Data("not json".utf8)))
|
||
|
||
XCTAssertNil(result)
|
||
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
|
||
}
|
||
|
||
func testResolveReturnsNilOnKeychainMiss() throws {
|
||
let result = resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([community()]),
|
||
privateKeys: [:]
|
||
))
|
||
|
||
XCTAssertNil(result)
|
||
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
|
||
}
|
||
|
||
func testResolveReturnsNilForNon2xxRelayResponse() throws {
|
||
URLProtocolStub.handler = { request in
|
||
Self.response(request, status: 503, data: Data())
|
||
}
|
||
let result = resolve(makeResolver(communitiesData: try snapshotData([community()])))
|
||
|
||
XCTAssertNil(result)
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
||
}
|
||
|
||
func testResolveReturnsNilForUndecodableRelayResponse() throws {
|
||
URLProtocolStub.handler = { request in
|
||
Self.response(request, status: 200, data: Data("not events".utf8))
|
||
}
|
||
let result = resolve(makeResolver(communitiesData: try snapshotData([community()])))
|
||
|
||
XCTAssertNil(result)
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
||
}
|
||
|
||
func testDecodeResolutionFiltersOwnPubkeyEvent() {
|
||
let result = BuzzPushNotificationResolver.decodeResolution(
|
||
events: [event(pubkey: Self.ownPubkey, content: "This should be filtered")],
|
||
community: community()
|
||
)
|
||
|
||
XCTAssertNil(result)
|
||
}
|
||
|
||
func testDecodeResolutionReturnsNilWhenSanitizedPreviewIsEmpty() {
|
||
let event = event(content: " \n\t ")
|
||
|
||
let result = BuzzPushNotificationResolver.decodeResolution(
|
||
events: [event],
|
||
community: community()
|
||
)
|
||
|
||
XCTAssertNil(result)
|
||
}
|
||
|
||
func testPreviewBodySanitizesCodeLinksAndWhitespace() {
|
||
let content = """
|
||
Before ```swift
|
||
print("secret")
|
||
``` `inline` [docs](https://example.com/docs)
|
||
 https://example.com/raw
|
||
After
|
||
"""
|
||
|
||
XCTAssertEqual(
|
||
BuzzPushNotificationResolver.previewBody(content),
|
||
"Before [code] inline docs image [link] After"
|
||
)
|
||
}
|
||
|
||
func testPreviewBodyTruncatesTo178CharactersIncludingEllipsis() {
|
||
let preview = BuzzPushNotificationResolver.previewBody(String(repeating: "x", count: 200))
|
||
|
||
XCTAssertEqual(preview.count, 178)
|
||
XCTAssertEqual(preview, String(repeating: "x", count: 177) + "…")
|
||
}
|
||
|
||
func testDecodeResolutionUsesLowestIDWhenCreatedAtTies() {
|
||
let result = BuzzPushNotificationResolver.decodeResolution(
|
||
events: [
|
||
event(id: "a", content: "lower ID", createdAt: Self.now),
|
||
event(id: "b", content: "higher ID", createdAt: Self.now),
|
||
],
|
||
community: community()
|
||
)
|
||
|
||
XCTAssertEqual(result?.1.id, "a")
|
||
XCTAssertEqual(result?.0.body, "lower ID")
|
||
}
|
||
|
||
func testDecodeResolutionSenderTitlesCanonicalizeKeysOrFallBackToNeutral() {
|
||
// The sender-identity presentation boundary: a verifiable key renders
|
||
// as the same compact npub whether it arrives as hex or as an npub, and
|
||
// an unverifiable key — including radix impostors like "+a"×32 that
|
||
// parse as hex pairs but are not literal hex keys — gets the neutral
|
||
// identity, never raw key material, while body, subtitle, and internal
|
||
// payload fields pass through untouched. Malformed-input classification
|
||
// itself is pinned at the codec (Bech32Tests); named senders winning
|
||
// over these labels is covered by the cached-profile resolve tests.
|
||
let senders: [(pubkey: String, title: String)] = [
|
||
(Self.unnamedSenderHex, "npub14f8…9nsy"),
|
||
(Self.unnamedSenderNpub, "npub14f8…9nsy"),
|
||
("author-pubkey", "Someone"),
|
||
(String(repeating: "+a", count: 32), "Someone"),
|
||
]
|
||
for sender in senders {
|
||
let result = BuzzPushNotificationResolver.decodeResolution(
|
||
events: [event(pubkey: sender.pubkey, content: "Preview")],
|
||
community: community()
|
||
)
|
||
|
||
XCTAssertEqual(result?.0.title, sender.title, "pubkey: \(sender.pubkey)")
|
||
XCTAssertEqual(result?.0.body, "Preview", "pubkey: \(sender.pubkey)")
|
||
XCTAssertEqual(result?.0.subtitle, "Community", "pubkey: \(sender.pubkey)")
|
||
XCTAssertEqual(result?.0.senderPubkey, sender.pubkey, "pubkey: \(sender.pubkey)")
|
||
XCTAssertEqual(result?.0.threadIdentifier, "community-id", "pubkey: \(sender.pubkey)")
|
||
}
|
||
}
|
||
|
||
func testResolveSucceedsAndMutatesGatewayContent() throws {
|
||
let event = try JSONDecoder().decode(
|
||
VerifiedNostrEvent.self,
|
||
from: Data(Self.fixtureEvent.utf8)
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
Self.response(request, status: 200, data: try JSONEncoder().encode([event]))
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([community()])
|
||
)))
|
||
|
||
XCTAssertNotEqual(result.title, Self.gatewayBody)
|
||
XCTAssertNotEqual(result.body, Self.gatewayBody)
|
||
XCTAssertEqual(result.title, "npub1ccz…mnyd")
|
||
XCTAssertEqual(result.body, "Hello Buzz")
|
||
XCTAssertEqual(result.subtitle, "Community")
|
||
XCTAssertEqual(
|
||
result.threadIdentifier,
|
||
BuzzPushPresentationIdentity.conversation(
|
||
communityID: "community-id",
|
||
channelID: Self.channelID
|
||
)
|
||
)
|
||
XCTAssertEqual(result.senderPubkey, event.pubkey)
|
||
XCTAssertEqual(
|
||
result.navigationTarget,
|
||
BuzzPushNavigationTarget(
|
||
eventID: event.id,
|
||
communityID: "community-id",
|
||
channelID: Self.channelID
|
||
)
|
||
)
|
||
}
|
||
|
||
func testFreshVerifiedCacheResolvesSenderAvatarAndChannelWithoutRefresh() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Hello from Alice"
|
||
)
|
||
let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey)
|
||
let avatar = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||
let snapshot = BuzzPushPresentationCacheSnapshot(
|
||
profiles: [
|
||
BuzzPushCachedProfile(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
pubkey: message.pubkey,
|
||
displayName: "Alice",
|
||
pictureHash: "picture-hash",
|
||
avatarPNG: avatar,
|
||
eventID: "profile-event",
|
||
eventCreatedAt: Self.now,
|
||
cachedAt: Self.now
|
||
)
|
||
],
|
||
channels: [
|
||
BuzzPushCachedChannel(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
channelID: Self.channelID,
|
||
relayMetadataPubkey: relayPubkey,
|
||
displayName: "General",
|
||
channelType: "stream",
|
||
memberCount: 2,
|
||
memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]),
|
||
membershipEventID: "membership-event",
|
||
membershipEventCreatedAt: Self.now,
|
||
membershipCachedAt: Self.now,
|
||
eventID: "channel-event",
|
||
eventCreatedAt: Self.now,
|
||
cachedAt: Self.now
|
||
)
|
||
]
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]),
|
||
presentationCacheData: try JSONEncoder().encode(snapshot),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "Alice")
|
||
XCTAssertEqual(result.senderAvatarPNG, avatar)
|
||
XCTAssertEqual(result.conversationDisplayName, "#General")
|
||
XCTAssertEqual(result.conversationRecipientCount, 1)
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
||
}
|
||
|
||
func testStaleVerifiedCacheIsUsedWhileOneBoundedRefreshFails() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Stale cache still presents"
|
||
)
|
||
let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey)
|
||
let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1
|
||
let snapshot = BuzzPushPresentationCacheSnapshot(
|
||
profiles: [
|
||
BuzzPushCachedProfile(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
pubkey: message.pubkey,
|
||
displayName: "Stale Alice",
|
||
pictureHash: nil,
|
||
avatarPNG: nil,
|
||
eventID: "profile-event",
|
||
eventCreatedAt: staleAt,
|
||
cachedAt: staleAt
|
||
)
|
||
],
|
||
channels: [
|
||
BuzzPushCachedChannel(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
channelID: Self.channelID,
|
||
relayMetadataPubkey: relayPubkey,
|
||
displayName: "Stale General",
|
||
channelType: "stream",
|
||
memberCount: 2,
|
||
memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]),
|
||
membershipEventID: "membership-event",
|
||
membershipEventCreatedAt: staleAt,
|
||
membershipCachedAt: staleAt,
|
||
eventID: "channel-event",
|
||
eventCreatedAt: staleAt,
|
||
cachedAt: staleAt
|
||
)
|
||
]
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
if URLProtocolStub.requests.count == 1 {
|
||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||
}
|
||
XCTAssertEqual(request.timeoutInterval, 3)
|
||
return Self.response(request, status: 503, data: Data())
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]),
|
||
presentationCacheData: try JSONEncoder().encode(snapshot),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "Stale Alice")
|
||
XCTAssertEqual(result.conversationDisplayName, "#Stale General")
|
||
XCTAssertEqual(result.conversationRecipientCount, 1)
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||
}
|
||
|
||
func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Keep newer cached metadata"
|
||
)
|
||
let olderProfile = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now - 20,
|
||
kind: 0,
|
||
content: #"{"display_name":"Older Alice"}"#
|
||
)
|
||
let olderChannel = try Self.signedEvent(
|
||
privateKey: Self.relayPrivateKey,
|
||
createdAt: Self.now - 20,
|
||
kind: 39_000,
|
||
tags: [["d", Self.channelID], ["name", "Older General"]]
|
||
)
|
||
let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1
|
||
let snapshot = BuzzPushPresentationCacheSnapshot(
|
||
profiles: [
|
||
BuzzPushCachedProfile(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
pubkey: message.pubkey,
|
||
displayName: "Newer Cached Alice",
|
||
pictureHash: nil,
|
||
avatarPNG: nil,
|
||
eventID: String(repeating: "f", count: 64),
|
||
eventCreatedAt: Self.now - 10,
|
||
cachedAt: staleAt
|
||
)
|
||
],
|
||
channels: [
|
||
BuzzPushCachedChannel(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
channelID: Self.channelID,
|
||
relayMetadataPubkey: olderChannel.pubkey,
|
||
displayName: "Newer Cached General",
|
||
channelType: "stream",
|
||
memberCount: 2,
|
||
memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]),
|
||
membershipEventID: "cached-membership",
|
||
membershipEventCreatedAt: Self.now - 10,
|
||
membershipCachedAt: staleAt,
|
||
eventID: String(repeating: "f", count: 64),
|
||
eventCreatedAt: Self.now - 10,
|
||
cachedAt: staleAt
|
||
)
|
||
]
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
if URLProtocolStub.requests.count == 1 {
|
||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||
}
|
||
return Self.response(
|
||
request,
|
||
status: 200,
|
||
data: try JSONEncoder().encode([olderProfile, olderChannel])
|
||
)
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([
|
||
community(relayMetadataPubkey: olderChannel.pubkey)
|
||
]),
|
||
presentationCacheData: try JSONEncoder().encode(snapshot),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "Newer Cached Alice")
|
||
XCTAssertEqual(result.conversationDisplayName, "#Newer Cached General")
|
||
}
|
||
|
||
func testChannelOnlyRefreshIgnoresUnrequestedProfileEvent() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Ignore unrelated enrichment"
|
||
)
|
||
let unexpectedProfile = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now + 1,
|
||
kind: 0,
|
||
content: #"{"display_name":"Unexpected Alice"}"#
|
||
)
|
||
let relayMetadataPubkey = try Self.pubkey(for: Self.relayPrivateKey)
|
||
let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1
|
||
let snapshot = BuzzPushPresentationCacheSnapshot(
|
||
profiles: [
|
||
BuzzPushCachedProfile(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
pubkey: message.pubkey,
|
||
displayName: "Cached Alice",
|
||
pictureHash: nil,
|
||
avatarPNG: nil,
|
||
eventID: "cached-profile",
|
||
eventCreatedAt: Self.now,
|
||
cachedAt: Self.now
|
||
)
|
||
],
|
||
channels: [
|
||
BuzzPushCachedChannel(
|
||
communityID: "community-id",
|
||
relayOrigin: "https://relay.example",
|
||
channelID: Self.channelID,
|
||
relayMetadataPubkey: relayMetadataPubkey,
|
||
displayName: "Stale General",
|
||
channelType: "stream",
|
||
memberCount: 2,
|
||
memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]),
|
||
membershipEventID: "cached-membership",
|
||
membershipEventCreatedAt: Self.now,
|
||
membershipCachedAt: Self.now,
|
||
eventID: "cached-channel",
|
||
eventCreatedAt: Self.now,
|
||
cachedAt: staleAt
|
||
)
|
||
]
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
if URLProtocolStub.requests.count == 1 {
|
||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||
}
|
||
return Self.response(
|
||
request,
|
||
status: 200,
|
||
data: try JSONEncoder().encode([unexpectedProfile])
|
||
)
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([
|
||
community(relayMetadataPubkey: relayMetadataPubkey)
|
||
]),
|
||
presentationCacheData: try JSONEncoder().encode(snapshot),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "Cached Alice")
|
||
XCTAssertEqual(result.conversationDisplayName, "#Stale General")
|
||
}
|
||
|
||
func testMissingCacheRefreshesVerifiedProfileAndChannelTogether() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Fresh metadata"
|
||
)
|
||
let profile = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 0,
|
||
content: #"{"display_name":"Fresh Alice"}"#
|
||
)
|
||
let channel = try Self.signedEvent(
|
||
privateKey: Self.relayPrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 39_000,
|
||
tags: [["d", Self.channelID], ["name", "Fresh General"], ["t", "stream"]]
|
||
)
|
||
let membership = try Self.signedEvent(
|
||
privateKey: Self.relayPrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 39_002,
|
||
tags: [
|
||
["d", Self.channelID],
|
||
["p", Self.ownPubkey],
|
||
["p", message.pubkey],
|
||
]
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
if URLProtocolStub.requests.count == 1 {
|
||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||
}
|
||
return Self.response(
|
||
request,
|
||
status: 200,
|
||
data: try JSONEncoder().encode([profile, channel, membership])
|
||
)
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([
|
||
community(relayMetadataPubkey: channel.pubkey)
|
||
]),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "Fresh Alice")
|
||
XCTAssertEqual(result.conversationDisplayName, "#Fresh General")
|
||
XCTAssertEqual(result.conversationRecipientCount, 1)
|
||
XCTAssertNil(result.senderAvatarPNG)
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||
}
|
||
|
||
func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Fallback content"
|
||
)
|
||
let validProfile = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 0,
|
||
content: #"{"display_name":"Tampered"}"#
|
||
)
|
||
let tamperedProfile = VerifiedNostrEvent(
|
||
id: validProfile.id,
|
||
pubkey: validProfile.pubkey,
|
||
createdAt: validProfile.createdAt,
|
||
kind: validProfile.kind,
|
||
tags: validProfile.tags,
|
||
content: #"{"display_name":"Mallory"}"#,
|
||
sig: validProfile.sig
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
if URLProtocolStub.requests.count == 1 {
|
||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||
}
|
||
return Self.response(
|
||
request,
|
||
status: 200,
|
||
data: try JSONEncoder().encode([tamperedProfile])
|
||
)
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([
|
||
community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey))
|
||
]),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "npub1ccz…mnyd")
|
||
XCTAssertNil(result.conversationDisplayName)
|
||
XCTAssertEqual(result.subtitle, "Community")
|
||
XCTAssertEqual(result.body, "Fallback content")
|
||
}
|
||
|
||
func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Bounded fallback"
|
||
)
|
||
let profile = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 0,
|
||
content: #"{"display_name":"Must Not Be Used"}"#
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
if URLProtocolStub.requests.count == 1 {
|
||
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
||
}
|
||
var oversized = Data(
|
||
repeating: 0x20,
|
||
count: BuzzPushNotificationResolver.maximumPresentationResponseBytes
|
||
)
|
||
oversized.append(try JSONEncoder().encode([profile]))
|
||
return Self.response(request, status: 200, data: oversized)
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([
|
||
community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey))
|
||
]),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "npub1ccz…mnyd")
|
||
XCTAssertNil(result.conversationDisplayName)
|
||
XCTAssertEqual(result.body, "Bounded fallback")
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||
}
|
||
|
||
func testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws {
|
||
let message = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 9,
|
||
tags: [["h", Self.channelID]],
|
||
content: "Inline avatar profile"
|
||
)
|
||
let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000)
|
||
let profileContent = try XCTUnwrap(
|
||
String(
|
||
data: JSONSerialization.data(withJSONObject: [
|
||
"display_name": "Fizz",
|
||
"picture": picture,
|
||
]),
|
||
encoding: .utf8
|
||
)
|
||
)
|
||
let profile = try Self.signedEvent(
|
||
privateKey: Self.profilePrivateKey,
|
||
createdAt: Self.now,
|
||
kind: 0,
|
||
content: profileContent
|
||
)
|
||
let presentationData = try JSONEncoder().encode([profile])
|
||
XCTAssertLessThan(
|
||
presentationData.count,
|
||
BuzzPushNotificationResolver.maximumPresentationResponseBytes
|
||
)
|
||
URLProtocolStub.handler = { request in
|
||
Self.response(
|
||
request,
|
||
status: 200,
|
||
data: URLProtocolStub.requests.count == 1
|
||
? try JSONEncoder().encode([message]) : presentationData
|
||
)
|
||
}
|
||
|
||
let result = try XCTUnwrap(
|
||
resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([community()]),
|
||
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
||
)
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(result.title, "Fizz")
|
||
XCTAssertNil(result.senderAvatarPNG)
|
||
XCTAssertEqual(result.body, "Inline avatar profile")
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
||
}
|
||
|
||
func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws {
|
||
URLProtocolStub.handler = { request in
|
||
XCTAssertEqual(request.url?.absoluteString, "https://relay.example/query")
|
||
return Self.response(request, status: 200, data: Data("[]".utf8))
|
||
}
|
||
|
||
let result = resolve(
|
||
makeResolver(
|
||
communitiesData: try snapshotData([community(relayUrl: "wss://relay.example")])
|
||
))
|
||
|
||
XCTAssertNil(result)
|
||
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
||
}
|
||
|
||
func makeResolver(
|
||
communitiesData: Data?,
|
||
privateKeys: [String: String] = ["community-id": privateKey],
|
||
presentationCacheData: Data? = nil,
|
||
now: Date = Date()
|
||
) -> BuzzPushNotificationResolver {
|
||
let configuration = URLSessionConfiguration.ephemeral
|
||
configuration.protocolClasses = [URLProtocolStub.self]
|
||
return BuzzPushNotificationResolver(
|
||
session: URLSession(configuration: configuration),
|
||
loadCommunitiesData: { communitiesData },
|
||
loadPrivateKey: { privateKeys[$0] },
|
||
loadPresentationCacheData: { presentationCacheData },
|
||
now: { now }
|
||
)
|
||
}
|
||
|
||
func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? {
|
||
let completed = expectation(description: "resolver completed")
|
||
var result: BuzzPushResolution?
|
||
resolver.resolve {
|
||
result = $0
|
||
completed.fulfill()
|
||
}
|
||
wait(for: [completed], timeout: 2)
|
||
return result
|
||
}
|
||
|
||
func community(
|
||
id: String = "community-id",
|
||
name: String = "Community",
|
||
relayUrl: String = "https://relay.example",
|
||
relayMetadataPubkey: String? = nil,
|
||
pubkey: String? = ownPubkey
|
||
) -> PushLeaseCommunity {
|
||
PushLeaseCommunity(
|
||
id: id,
|
||
name: name,
|
||
relayUrl: relayUrl,
|
||
relayMetadataPubkey: relayMetadataPubkey,
|
||
pubkey: pubkey,
|
||
policies: [
|
||
PushResolutionPolicy(
|
||
filter: PushLeaseFilter(
|
||
kinds: [9, 40002, 45001, 45003],
|
||
hTags: [Self.channelID]
|
||
)
|
||
)
|
||
]
|
||
)
|
||
}
|
||
|
||
func snapshotData(_ communities: [PushLeaseCommunity]) throws -> Data {
|
||
try JSONEncoder().encode(PushLeaseSnapshot(communities: communities))
|
||
}
|
||
|
||
private func event(
|
||
id: String = "event-id",
|
||
pubkey: String = "author-pubkey",
|
||
content: String,
|
||
createdAt: Int = now,
|
||
kind: Int = 9,
|
||
tags: [[String]] = []
|
||
) -> VerifiedNostrEvent {
|
||
VerifiedNostrEvent(
|
||
id: id,
|
||
pubkey: pubkey,
|
||
createdAt: createdAt,
|
||
kind: kind,
|
||
tags: tags,
|
||
content: content,
|
||
sig: "signature"
|
||
)
|
||
}
|
||
|
||
private static let fixtureEvent = #"""
|
||
{"kind":9,"created_at":1785551670,"tags":[["h","123e4567-e89b-42d3-a456-426614174000"]],"content":" Hello [Buzz](https://buzz.block.xyz) ","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","id":"233ccf24ec7c94808f9ef08b0c986b6df1bc3843ff72a9f8d016e2a77c77429b","sig":"d39dcd413839b872ed75a979b2c1542247fde636709966905c9e424e227a43897dc67b71ec84178a3faad0634f9bcdf0b48a56ebac84a2ac6e58124b8b6476e6"}
|
||
"""#
|
||
|
||
static func response(
|
||
_ request: URLRequest,
|
||
status: Int,
|
||
data: Data
|
||
) -> (HTTPURLResponse, Data) {
|
||
let response = HTTPURLResponse(
|
||
url: request.url!,
|
||
statusCode: status,
|
||
httpVersion: "HTTP/1.1",
|
||
headerFields: ["Content-Type": "application/json"]
|
||
)!
|
||
return (response, data)
|
||
}
|
||
|
||
static func pubkey(for privateKey: String) throws -> String {
|
||
let bytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey))
|
||
let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes)
|
||
return VerifiedNostrEvent.hex(key.xonly.bytes)
|
||
}
|
||
|
||
static func memberDigests(_ pubkeys: [String]) -> [String] {
|
||
pubkeys.map {
|
||
BuzzPushPresentationIdentity.channelMember(
|
||
communityID: "community-id",
|
||
channelID: channelID,
|
||
pubkey: $0
|
||
)
|
||
}.sorted()
|
||
}
|
||
|
||
static func signedEvent(
|
||
privateKey: String,
|
||
createdAt: Int,
|
||
kind: Int,
|
||
tags: [[String]] = [],
|
||
content: String = ""
|
||
) throws -> VerifiedNostrEvent {
|
||
let privateKeyBytes = try XCTUnwrap(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)
|
||
)
|
||
}
|
||
|
||
final class URLProtocolStub: URLProtocol, @unchecked Sendable {
|
||
static let lock = NSLock()
|
||
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
||
static var requests: [URLRequest] = []
|
||
|
||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||
|
||
override func startLoading() {
|
||
Self.lock.lock()
|
||
Self.requests.append(request)
|
||
let handler = Self.handler
|
||
Self.lock.unlock()
|
||
do {
|
||
let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }()
|
||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||
client?.urlProtocol(self, didLoad: data)
|
||
client?.urlProtocolDidFinishLoading(self)
|
||
} catch {
|
||
client?.urlProtocol(self, didFailWithError: error)
|
||
}
|
||
}
|
||
|
||
override func stopLoading() {}
|
||
|
||
static func reset() {
|
||
lock.lock()
|
||
handler = nil
|
||
requests = []
|
||
lock.unlock()
|
||
}
|
||
}
|
||
}
|