mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Tapping a push notification for a thread reply currently opens the channel at its parent. Preserve the verified event's direct-parent thread ID through the iOS notification payload and Flutter bridge so the tap opens the containing thread and highlights that exact reply, even when newer replies have arrived. This matches Inbox's destination selection for nested replies; broadcast replies still open in the channel. Closest related work: https://github.com/block/buzz/pull/6159 (original push-tap routing). ### Validation - Follow-up [5b70e1a](5b70e1a5cb) selects the last valid 64-character ASCII-hex reply marker, matching relay ingest. Regression tests failed before the fix; all 80 XCTest and 47 Swift Testing tests passed afterward with Xcode 26.5. [Current-head CI](https://github.com/block/buzz/actions/runs/38607128406) passed, including Flutter tests, Android build, native package tests, complete iOS simulator and unsigned release builds, and native accessibility tests. The [current-head security review](https://github.com/block/buzz/pull/8294#issuecomment-6174205374) reports no findings. Physical evidence below predates this validation-only follow-up. - [Baseline CI onf465f91](https://github.com/block/buzz/actions/runs/38436936153) passed, including Flutter and native package tests, Android build, complete iOS simulator and unsigned release builds, and native accessibility tests. - Full local validation passed on [f465f91](f465f9196f), including 3,215 Flutter tests (four skipped) and three unconfigured-push tests. The 49 focused navigation tests also passed after the baseline merge. - Added coverage for cold/warm bridge delivery, exact reply highlighting with newer arrivals, direct-parent and broadcast destinations, and malformed optional thread IDs. The Codex finding for an empty reply marker is fixed and resolved. - Physical acceptance passed on the managed iPhone 12 mini using signed Buzz Dev 10140, built fromf465f91with SDK 26.5. Real push taps and Inbox opened the same containing thread and highlighted the exact reply. An older alert retained its target after a newer reply arrived; a retained alert also worked after terminating the app. Top-level push navigation reached the channel. - Installed in place with the existing identity, app data and pairing retained. No key reset or rotation was performed. Notifications were restored to off and device control released. Xcode 27 hits an unchanged dependency compiler error; the matching Xcode 26.5 toolchain built and signed this tested candidate successfully. ### Physical acceptance | Warm push: older reply A | Inbox: reply B | Cold push: reply B | | --- | --- | --- | |  |  |  | [Physical test details](https://github.com/block/buzz/pull/8294#issuecomment-6151991840). ### Before and after Synthetic Flutter widget captures with a newer reply already present. | Before: channel at outer parent | After: exact reply in containing thread | | --- | --- | |  |  | After merge, repeat warm/cold push versus Inbox in the integrated mobile candidate, including older alerts, top-level and broadcast notifications. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
917 lines
30 KiB
Swift
917 lines
30 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 testReplyNavigationUsesDirectParentAndKeepsExactEvent() throws {
|
||
let parent = String(repeating: "a", count: 64)
|
||
let reply = event(id: "reply-id", content: "Reply preview", tags: [
|
||
["h", Self.channelID],
|
||
["e", "outer-root", "", "root"],
|
||
["e", parent, "", "reply"],
|
||
["e", "bad", "", "reply"],
|
||
["e", "", "", "reply"],
|
||
])
|
||
let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution(
|
||
events: [reply], community: community()
|
||
)?.0)
|
||
let target = try XCTUnwrap(resolution.navigationTarget)
|
||
XCTAssertEqual(target.eventID, "reply-id")
|
||
XCTAssertEqual(target.userInfoValue["thread_root_id"], parent)
|
||
}
|
||
|
||
func testNavigationKeepsTopLevelAndBroadcastMessagesInChannel() throws {
|
||
for tags in [
|
||
[["h", Self.channelID]],
|
||
[["h", Self.channelID], ["e", "mention", "", "mention"]],
|
||
[["h", Self.channelID], ["e", "root", "", "root"]],
|
||
[["h", Self.channelID], ["e", "parent", "", "reply"], ["broadcast", "1"]],
|
||
] {
|
||
let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution(
|
||
events: [event(content: "Preview", tags: tags)], community: community()
|
||
)?.0)
|
||
XCTAssertNil(resolution.navigationTarget?.threadRootID)
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
}
|