mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
## Context Pushes from the gateway carry no event reference (`crates/buzz-push-gateway/src/model.rs`), so the iOS Notification Service Extension resolves each push by querying the user's NIP-PL lease filters and showing the newest matching message. Any push whose trigger wasn't the newest message (a reaction, an edit or deletion, a community query that failed, or several pushes arriving for one message) showed that same newest message again. Tom saw one message notify four times over three hours. ## Behavior - The extension now remembers which messages it has shown, per community and account, in its existing notification cache (`BuzzNotificationCache`). - Each push shows the newest message not yet shown, across all communities. If every candidate was already shown, it shows the generic "You have a new message." text. - A message more than 15 minutes older than the newest one already shown, in any configured community (including one whose query failed), is treated as already covered. So a stale backlog item can't surface hours later, even from a community where nothing has been shown yet. 15 minutes matches the relay's `created_at` drift bound (`MAX_TIMESTAMP_DRIFT_SECS`), so a sender with a slow clock still gets through. A future-dated `created_at` is capped at the time it was shown, so it can't hold back later messages. - Selection and claim happen in one locked cache call. It reads each community's store once and writes only when it records a claim, so concurrent pushes can't claim against a stale view, and a rejected candidate never rewrites the store. - Retention drops only entries the age check would reject anyway. The rest are capped at the union of the newest 256 by capped time and the newest 256 by `created_at`. So neither a burst of messages nor future-dated entries can evict a message the relay still returns. The field is optional, so existing caches still decode with no version bump. - Only the newest 10 matches per community are fetched, as before. If more than 10 messages arrive before their pushes resolve, the extra pushes show the generic text. - The claim happens before presentation. If presentation then fails, that push shows the generic text and the message isn't offered again, which is better than repeating. - If the cache can't be read, dedupe is skipped (fail open), so new messages are never hidden. If a claim can't be written, the new message is still shown. An already-shown message is still rejected, since rejecting needs no write. A revoked scope is skipped. - Dedupe state lives in the extension's `Caches` directory, which iOS can purge. After a purge, the next push can show the newest message again, once. This is the NSE-only fix. It doesn't change the gateway payload or NIP-PL. ## Validation - New test `testEachPushShowsANewMessageAndNeverRepeatsOne` uses a fresh cache instance per push, to model separate extension processes. It covers: a repeat push giving the generic text; out-of-order resolution (late, then early, then nothing); a stale message outside the window; and the future-date clamp. I checked by mutation that removing any one of the three guards (id check, window, clamp) fails it. - New test `testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts` covers the review findings. It checks that: - an old message isn't shown from a community with nothing shown yet, including when the other community's query fails; - a burst of 100 newer messages doesn't evict a shown mention the relay still returns; - a read-only store neither repeats a shown message nor hides a new one; - a future-dated message isn't shown again after later messages. - New test `testFutureDatedEntriesDoNotEvictAClaimedMessage`: 257 future-dated claims don't evict a just-claimed message. - Mutation checks all fail these tests: a candidate-only watermark, retention by `created_at` only, a 64-entry cap, and pruning by capped time. - Full BuzzPushKit suite on `15913d0`: 106 XCTest and 65 Swift Testing tests passed. - Live relay run (on `baac384`): a scratch test drove the real resolver and cache against a local relay. One key posted messages and another resolved five pushes (not committed): | Push | `main` | This branch | | --- | --- | --- | | 1 (after "Hello 1") | Hello 1 | Hello 1 | | 2 (no new message) | Hello 1 | You have a new message. | | 3 (after "Hello 2", "Hello 3") | Hello 3 | Hello 3 | | 4 (no new message) | Hello 3 | Hello 2 | | 5 (no new message) | Hello 3 | You have a new message. | - Automated app check: built and launched the branch on an iOS 27 simulator. The app started and showed the signed-in home screen. Only the extension uses the changed types. - An independent fresh-context review found no blockers. Its comment suggestions were applied. ### Limitations I couldn't run a real APNs push end to end. Dev-signed builds get no gateway pushes, and `simctl push` on iOS 27 doesn't invoke the Notification Service Extension. The live relay run covers the resolver and cache logic the extension runs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1422 lines
53 KiB
Swift
1422 lines
53 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
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 testInitialAccountFanoutReadsSnapshotOnce() throws {
|
|
let communities = (0..<64).map { community(id: "community-\($0)") }
|
|
let snapshot = try snapshotData(communities)
|
|
let lock = NSLock()
|
|
var reads = 0
|
|
let started = expectation(description: "All community requests started")
|
|
started.expectedFulfillmentCount = communities.count
|
|
let finished = expectation(description: "Cancelled requests complete resolution")
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [URLProtocolStub.self]
|
|
let session = URLSession(configuration: configuration)
|
|
URLProtocolStub.handler = { request in
|
|
started.fulfill()
|
|
return Self.response(request, status: 200, data: Data("[]".utf8))
|
|
}
|
|
URLProtocolStub.finishLoading = { _ in false }
|
|
let resolver = BuzzPushNotificationResolver(session: session,
|
|
loadCommunitiesData: {
|
|
lock.lock()
|
|
reads += 1
|
|
lock.unlock()
|
|
return snapshot
|
|
}, loadPrivateKey: { _ in Self.privateKey })
|
|
resolver.resolve { result in
|
|
XCTAssertNil(result)
|
|
finished.fulfill()
|
|
}
|
|
wait(for: [started], timeout: 3)
|
|
lock.lock()
|
|
let initialReads = reads
|
|
lock.unlock()
|
|
XCTAssertEqual(initialReads, 1)
|
|
session.invalidateAndCancel()
|
|
wait(for: [finished], timeout: 3)
|
|
}
|
|
|
|
func testSnapshotAccountMustMatchKeychainBeforeAnyRequest() throws {
|
|
let configured = community(pubkey: try Self.pubkey(for: Self.profilePrivateKey))
|
|
URLProtocolStub.handler = { request in
|
|
XCTFail("Mismatched account key must not issue a request")
|
|
return Self.response(request, status: 200, data: Data("[]".utf8))
|
|
}
|
|
XCTAssertNil(resolve(makeResolver(communitiesData: try snapshotData([configured]))))
|
|
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
|
|
}
|
|
|
|
func testKeyReplacementWhileRequestIsInFlightRejectsItsResult() throws {
|
|
let data = try snapshotData([community()])
|
|
let lock = NSLock()
|
|
var key = Self.privateKey
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [URLProtocolStub.self]
|
|
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
|
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Old account result")
|
|
URLProtocolStub.handler = { request in
|
|
lock.lock()
|
|
key = Self.profilePrivateKey
|
|
lock.unlock()
|
|
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
|
}
|
|
let resolver = BuzzPushNotificationResolver(
|
|
session: URLSession(configuration: configuration), loadCommunitiesData: { data },
|
|
loadPrivateKey: { _ in
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return key
|
|
})
|
|
XCTAssertNil(resolve(resolver))
|
|
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
|
}
|
|
|
|
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 testResolveRetriesStaleConnectionOnceWithFreshProof() throws {
|
|
let event = try JSONDecoder().decode(
|
|
VerifiedNostrEvent.self,
|
|
from: Data(Self.fixtureEvent.utf8)
|
|
)
|
|
for code in [URLError.Code.timedOut, .networkConnectionLost] {
|
|
URLProtocolStub.reset()
|
|
var failed = false
|
|
URLProtocolStub.handler = { request in
|
|
if !failed {
|
|
failed = true
|
|
throw URLError(code)
|
|
}
|
|
return Self.response(request, status: 200, data: try JSONEncoder().encode([event]))
|
|
}
|
|
|
|
var retrySessions = 0
|
|
let result = try XCTUnwrap(
|
|
resolve(
|
|
makeResolver(
|
|
communitiesData: try snapshotData([community()]),
|
|
makeRetrySession: {
|
|
retrySessions += 1
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [URLProtocolStub.self]
|
|
configuration.httpAdditionalHeaders = ["X-Test-Session": "retry"]
|
|
return URLSession(configuration: configuration)
|
|
})), "\(code)")
|
|
|
|
XCTAssertEqual(result.body, "Hello Buzz")
|
|
XCTAssertEqual(retrySessions, 1)
|
|
let queries = URLProtocolStub.requests.prefix(2)
|
|
XCTAssertEqual(queries.map { $0.url?.path }, ["/query", "/query"])
|
|
XCTAssertEqual(
|
|
queries.map { $0.value(forHTTPHeaderField: "X-Test-Session") }, [nil, "retry"],
|
|
"The retry must not reuse the session whose connection failed.")
|
|
XCTAssertNotEqual(
|
|
queries.first?.value(forHTTPHeaderField: "Authorization"),
|
|
queries.last?.value(forHTTPHeaderField: "Authorization"),
|
|
"The relay rejects a replayed NIP-98 proof.")
|
|
}
|
|
}
|
|
|
|
func testResolveRetriesStaleConnectionOnlyOnce() throws {
|
|
URLProtocolStub.handler = { _ in throw URLError(.timedOut) }
|
|
|
|
XCTAssertNil(resolve(makeResolver(communitiesData: try snapshotData([community()]))))
|
|
XCTAssertEqual(URLProtocolStub.requests.count, 2)
|
|
}
|
|
|
|
func testResolveDoesNotRetryAfterAccountKeyChanges() throws {
|
|
let data = try snapshotData([community()])
|
|
let lock = NSLock()
|
|
var key = Self.privateKey
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [URLProtocolStub.self]
|
|
URLProtocolStub.handler = { _ in
|
|
lock.lock()
|
|
key = Self.profilePrivateKey
|
|
lock.unlock()
|
|
throw URLError(.timedOut)
|
|
}
|
|
let resolver = BuzzPushNotificationResolver(
|
|
session: URLSession(configuration: configuration), loadCommunitiesData: { data },
|
|
loadPrivateKey: { _ in
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return key
|
|
})
|
|
|
|
XCTAssertNil(resolve(resolver))
|
|
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
|
}
|
|
|
|
func testCancelStopsAPendingRetry() throws {
|
|
let resolver = makeResolver(communitiesData: try snapshotData([community()]))
|
|
URLProtocolStub.handler = { _ in
|
|
resolver.cancel()
|
|
throw URLError(.timedOut)
|
|
}
|
|
|
|
XCTAssertNil(resolve(resolver))
|
|
XCTAssertEqual(URLProtocolStub.requests.count, 1)
|
|
}
|
|
|
|
func testResolveDoesNotRetryOtherTransportFailures() throws {
|
|
URLProtocolStub.handler = { _ in throw URLError(.notConnectedToInternet) }
|
|
|
|
XCTAssertNil(resolve(makeResolver(communitiesData: try snapshotData([community()]))))
|
|
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 testMissingSenderNameOmitsLabelWithoutExposingKey() {
|
|
// Keep identity in the routing payload, but do not use it as a display name.
|
|
let senders: [(pubkey: String, title: String)] = [
|
|
(Self.unnamedSenderHex, ""),
|
|
(Self.unnamedSenderNpub, ""),
|
|
("author-pubkey", ""),
|
|
(String(repeating: "+a", count: 32), ""),
|
|
]
|
|
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, "")
|
|
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
|
|
)
|
|
)
|
|
}
|
|
|
|
/// A push carries no event reference. Each push must show a message not shown before,
|
|
/// including after the extension process restarts, instead of the newest match again.
|
|
func testEachPushShowsANewMessageAndNeverRepeatsOne() throws {
|
|
let snapshot = try snapshotData([community()])
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community()))
|
|
let now = Date(timeIntervalSince1970: TimeInterval(Self.now))
|
|
// A fresh cache instance per push models a fresh extension process.
|
|
func push() -> String? {
|
|
resolve(makeResolver(communitiesData: snapshot,
|
|
cacheOverride: BuzzNotificationCache(directory: root, now: { now }, isCurrent: { $0 == scope }),
|
|
now: now))?.body
|
|
}
|
|
var relayEvents: [VerifiedNostrEvent] = []
|
|
URLProtocolStub.handler = { request in
|
|
Self.response(request, status: 200, data: try JSONEncoder().encode(relayEvents))
|
|
}
|
|
func message(_ content: String, secondsAgo: Int) throws -> VerifiedNostrEvent {
|
|
try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now - secondsAgo,
|
|
kind: 9, tags: [["h", Self.channelID]], content: content)
|
|
}
|
|
let window = BuzzNotificationCache.presentationReorderWindow
|
|
|
|
let history = try message("Last week", secondsAgo: 7 * 24 * 3600)
|
|
let first = try message("First", secondsAgo: 3600)
|
|
relayEvents = [history, first]
|
|
XCTAssertEqual(push(), "First", "with nothing shown yet, the newest message")
|
|
XCTAssertNil(push(), "a push for a non-message event must not repeat it")
|
|
|
|
// Two messages whose pushes resolve out of order: the later push wins the first
|
|
// resolution, so the earlier push must still find its own message.
|
|
let early = try message("Early", secondsAgo: 60)
|
|
let late = try message("Late", secondsAgo: 59)
|
|
relayEvents = [late, early, first, history]
|
|
XCTAssertEqual(push(), "Late")
|
|
XCTAssertEqual(push(), "Early")
|
|
XCTAssertNil(push())
|
|
|
|
// Older history is never surfaced by a later push.
|
|
let stale = try message("Stale", secondsAgo: 59 + window + 1)
|
|
relayEvents = [late, early, stale, first, history]
|
|
XCTAssertNil(push(), "a message older than the reorder window is history")
|
|
|
|
// A future-dated message is shown once and doesn't hide messages that follow it.
|
|
let future = try message("Future", secondsAgo: -3600)
|
|
relayEvents = [future, late]
|
|
XCTAssertEqual(push(), "Future")
|
|
let next = try message("Next", secondsAgo: 0)
|
|
relayEvents = [future, next, late]
|
|
XCTAssertEqual(push(), "Next")
|
|
XCTAssertNil(push())
|
|
}
|
|
|
|
func testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts() throws {
|
|
let a = community(id: "community-a", relayUrl: "https://a.example")
|
|
let b = community(id: "community-b", relayUrl: "https://b.example")
|
|
let snapshot = try snapshotData([a, b])
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
|
let scopes = [a, b].compactMap { BuzzNotificationCacheScope(community: $0) }
|
|
var clock = Self.now
|
|
func push() -> String? {
|
|
let now = Date(timeIntervalSince1970: TimeInterval(clock))
|
|
return resolve(makeResolver(communitiesData: snapshot,
|
|
privateKeys: ["community-a": Self.privateKey, "community-b": Self.privateKey],
|
|
cacheOverride: BuzzNotificationCache(
|
|
directory: root, now: { now }, isCurrent: { scopes.contains($0) }),
|
|
now: now))?.body
|
|
}
|
|
var relayEvents: [String: [VerifiedNostrEvent]] = [:]
|
|
var failingHosts: Set<String> = []
|
|
URLProtocolStub.handler = { request in
|
|
let host = request.url?.host ?? ""
|
|
return Self.response(request, status: failingHosts.contains(host) ? 500 : 200,
|
|
data: try JSONEncoder().encode(relayEvents[host] ?? []))
|
|
}
|
|
func message(_ content: String, createdAt: Int) throws -> VerifiedNostrEvent {
|
|
try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: createdAt,
|
|
kind: 9, tags: [["h", Self.channelID]], content: content)
|
|
}
|
|
|
|
// B has shown nothing yet, but A's shown message still marks B's last week as
|
|
// history, even when A's query fails.
|
|
let mention = try message("A mention", createdAt: clock - 60)
|
|
relayEvents["a.example"] = [mention]
|
|
relayEvents["b.example"] = [try message("B last week", createdAt: clock - 7 * 24 * 3600)]
|
|
XCTAssertEqual(push(), "A mention")
|
|
XCTAssertNil(push(), "an old message in another community is not new")
|
|
failingHosts = ["a.example"]
|
|
XCTAssertNil(push(), "a failed query doesn't drop its community's history")
|
|
failingHosts = []
|
|
|
|
// A burst of newer messages doesn't evict one the relay still returns.
|
|
var newest = mention
|
|
for index in 0..<100 {
|
|
clock += 1
|
|
newest = try message("Burst \(index)", createdAt: clock)
|
|
relayEvents["a.example"] = [newest, mention]
|
|
XCTAssertEqual(push(), "Burst \(index)")
|
|
}
|
|
XCTAssertNil(push(), "a shown message is not shown again after a burst")
|
|
|
|
// A store that can't be written still remembers what it already shows.
|
|
let directory = root.appendingPathComponent(scopes[0].directoryName, isDirectory: true)
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: directory.path)
|
|
addTeardownBlock {
|
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path)
|
|
}
|
|
XCTAssertNil(push(), "a read-only store does not repeat a shown message")
|
|
relayEvents["a.example"] = [try message("Unrecorded", createdAt: clock), newest, mention]
|
|
XCTAssertEqual(push(), "Unrecorded", "an unwritable store never hides a new message")
|
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: directory.path)
|
|
|
|
// A future-dated message stays remembered while the relay keeps returning it first,
|
|
// even after later messages push its capped time out of the window.
|
|
let future = try message("Future", createdAt: clock + 3600)
|
|
relayEvents["a.example"] = [future]
|
|
XCTAssertEqual(push(), "Future")
|
|
for index in 0..<40 {
|
|
clock += 30
|
|
relayEvents["a.example"] = [future, try message("Later \(index)", createdAt: clock)]
|
|
XCTAssertEqual(push(), "Later \(index)")
|
|
}
|
|
XCTAssertNil(push(), "a future-dated message is not shown again")
|
|
}
|
|
|
|
func testFutureDatedEntriesDoNotEvictAClaimedMessage() throws {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
|
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: community()))
|
|
let cache = BuzzNotificationCache(
|
|
directory: root, now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) },
|
|
isCurrent: { $0 == scope })
|
|
func claim(_ event: VerifiedNostrEvent) throws -> Bool {
|
|
try cache.claimFirstUnpresented([(event: event, scope: scope)], scopes: [scope]) == 0
|
|
}
|
|
for index in 0..<(BuzzNotificationCache.maximumPresentedMessages + 1) {
|
|
let ahead = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
|
createdAt: Self.now + 600 + index, kind: 9, tags: [["h", Self.channelID]],
|
|
content: "Ahead \(index)")
|
|
XCTAssertTrue(try claim(ahead))
|
|
}
|
|
let current = try Self.signedEvent(privateKey: Self.profilePrivateKey, createdAt: Self.now,
|
|
kind: 9, tags: [["h", Self.channelID]], content: "Current")
|
|
XCTAssertTrue(try claim(current))
|
|
XCTAssertFalse(try claim(current), "a claimed message survives retention")
|
|
}
|
|
|
|
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 testStaleVerifiedCacheIsUsedWhileBoundedRefreshesFail() 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, 60)
|
|
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, 4)
|
|
}
|
|
|
|
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 testLegacyAccountSnapshotEnrichesBeforeFirstAppLaunchAfterUpgrade() throws {
|
|
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
|
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Hello before app launch")
|
|
let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
|
createdAt: Self.now, kind: 0, content: #"{"name":"Alice"}"#)
|
|
var legacy = community()
|
|
legacy.notificationSessionID = nil
|
|
URLProtocolStub.handler = { request in
|
|
Self.response(request, status: 200,
|
|
data: try JSONEncoder().encode(URLProtocolStub.requests.count == 1 ? [message] : [profile]))
|
|
}
|
|
let result = try XCTUnwrap(resolve(makeResolver(communitiesData: try snapshotData([legacy]))))
|
|
XCTAssertEqual(result.title, "Alice")
|
|
XCTAssertEqual(result.body, "Hello before app launch")
|
|
XCTAssertNotNil(result.notificationScope)
|
|
var newSignIn = legacy
|
|
newSignIn.notificationSessionID = UUID().uuidString
|
|
XCTAssertNotEqual(result.notificationScope, BuzzNotificationCacheScope(community: newSignIn))
|
|
}
|
|
|
|
func testMissingCacheRefreshesVerifiedProfileAndChannelIndependently() 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, 4)
|
|
}
|
|
|
|
func testOnlyOwnSoloChatConversationNameDropsChannelHash() throws {
|
|
let message = try Self.signedEvent(
|
|
privateKey: Self.profilePrivateKey,
|
|
createdAt: Self.now,
|
|
kind: 9,
|
|
tags: [["h", Self.channelID]],
|
|
content: "Agent reply"
|
|
)
|
|
// Like the app's Channel.isPersonalChat: only the viewer's own Solo chat
|
|
// is personal; someone else's Solo chat stays an ordinary channel.
|
|
for (creator, expected) in [
|
|
(Self.ownPubkey, "Trip planning"), (message.pubkey, "#Trip planning"),
|
|
] {
|
|
URLProtocolStub.requests = []
|
|
let channel = try Self.signedEvent(
|
|
privateKey: Self.relayPrivateKey,
|
|
createdAt: Self.now,
|
|
kind: 39_000,
|
|
tags: [
|
|
["d", Self.channelID], ["name", "Trip planning"], ["t", "stream"],
|
|
["t", "buzz.solo"], ["P", creator], ["private"],
|
|
]
|
|
)
|
|
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([channel, membership]))
|
|
}
|
|
|
|
let result = try XCTUnwrap(
|
|
resolve(
|
|
makeResolver(
|
|
communitiesData: try snapshotData([
|
|
community(relayMetadataPubkey: channel.pubkey)
|
|
]),
|
|
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
|
)
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(result.conversationDisplayName, expected)
|
|
}
|
|
}
|
|
|
|
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, "")
|
|
XCTAssertNil(result.conversationDisplayName)
|
|
XCTAssertEqual(result.subtitle, "Community")
|
|
XCTAssertEqual(result.body, "Fallback content")
|
|
}
|
|
|
|
func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws {
|
|
let cancelled = expectation(description: "Oversized streaming responses are cancelled")
|
|
cancelled.expectedFulfillmentCount = 3
|
|
URLProtocolStub.finishLoading = { _ in URLProtocolStub.requests.count == 1 }
|
|
URLProtocolStub.onStopLoading = { _ in
|
|
if URLProtocolStub.requests.count > 1 { cancelled.fulfill() }
|
|
}
|
|
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, "")
|
|
XCTAssertNil(result.conversationDisplayName)
|
|
XCTAssertEqual(result.body, "Bounded fallback")
|
|
XCTAssertEqual(URLProtocolStub.requests.count, 4)
|
|
wait(for: [cancelled], timeout: 1)
|
|
}
|
|
|
|
func testLargeRosterCannotDiscardSenderProfileOrChannelMetadata() throws {
|
|
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
|
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Large channel")
|
|
let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
|
createdAt: Self.now, kind: 0, content: #"{"display_name":"Fizz"}"#)
|
|
let members = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
|
createdAt: Self.now, kind: 39002,
|
|
tags: [["d", Self.channelID]] + (0..<30000).map { ["p", String(format: "%064x", $0)] })
|
|
let channel = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
|
createdAt: Self.now, kind: 39000,
|
|
tags: [["d", Self.channelID], ["name", "Large Channel"], ["t", "stream"]])
|
|
let roster = try JSONEncoder().encode([profile, channel, members])
|
|
XCTAssertGreaterThan(roster.count, BuzzPushNotificationResolver.maximumPresentationResponseBytes)
|
|
URLProtocolStub.handler = { request in
|
|
if URLProtocolStub.requests.count == 1 {
|
|
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
|
|
}
|
|
var body = request.httpBody ?? Data()
|
|
if let stream = request.httpBodyStream {
|
|
stream.open()
|
|
defer { stream.close() }
|
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
|
while stream.hasBytesAvailable {
|
|
let count = stream.read(&buffer, maxLength: buffer.count)
|
|
if count <= 0 { break }
|
|
body.append(contentsOf: buffer.prefix(count))
|
|
}
|
|
}
|
|
let filters = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [[String: Any]])
|
|
let kinds = filters.flatMap { $0["kinds"] as? [Int] ?? [] }
|
|
return Self.response(request, status: 200,
|
|
data: kinds.contains(39002) ? roster : try JSONEncoder().encode(kinds.contains(0) ? [profile] : [channel]))
|
|
}
|
|
let configured = community(relayMetadataPubkey: members.pubkey)
|
|
let scope = try XCTUnwrap(BuzzNotificationCacheScope(community: configured))
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: root) }
|
|
let cache = BuzzNotificationCache(directory: root, isCurrent: { $0 == scope })
|
|
let result = try XCTUnwrap(resolve(makeResolver(
|
|
communitiesData: try snapshotData([configured]), cacheOverride: cache,
|
|
now: Date(timeIntervalSince1970: TimeInterval(Self.now)))))
|
|
XCTAssertEqual(result.title, "Fizz")
|
|
XCTAssertEqual(result.body, "Large channel")
|
|
XCTAssertEqual(try cache.metadata(scope: scope, kind: 39000,
|
|
author: channel.pubkey, identifier: Self.channelID)?.event, channel)
|
|
// Group specialization still requires a verified complete recipient count.
|
|
XCTAssertNil(result.conversationRecipientCount)
|
|
}
|
|
|
|
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: 262_050)
|
|
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 channel = try Self.signedEvent(
|
|
privateKey: Self.relayPrivateKey, createdAt: Self.now, kind: 39000,
|
|
tags: [["d", Self.channelID], ["name", "Large Channel"], ["t", "stream"]],
|
|
content: String(repeating: "\"", count: 256 * 1024)
|
|
)
|
|
let membership = try Self.signedEvent(
|
|
privateKey: Self.relayPrivateKey, createdAt: Self.now, kind: 39002,
|
|
tags: [["d", Self.channelID], ["p", Self.ownPubkey]],
|
|
content: String(repeating: "\"", count: 256 * 1024)
|
|
)
|
|
let presentationData = try JSONEncoder().encode([profile, channel, membership])
|
|
XCTAssertLessThanOrEqual(profileContent.utf8.count, 256 * 1024)
|
|
XCTAssertGreaterThan(presentationData.count, 256 * 1024)
|
|
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(relayMetadataPubkey: channel.pubkey)]),
|
|
now: Date(timeIntervalSince1970: TimeInterval(Self.now))
|
|
)
|
|
)
|
|
)
|
|
|
|
XCTAssertEqual(result.title, "Fizz")
|
|
XCTAssertEqual(result.conversationDisplayName, "#Large Channel")
|
|
XCTAssertEqual(result.conversationRecipientCount, 1)
|
|
XCTAssertNil(result.senderAvatarPNG)
|
|
XCTAssertEqual(result.body, "Inline avatar profile")
|
|
XCTAssertEqual(URLProtocolStub.requests.count, 4)
|
|
}
|
|
|
|
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,
|
|
cacheOverride: BuzzNotificationCache? = nil,
|
|
now: Date = Date(),
|
|
makeRetrySession: (() -> URLSession)? = nil
|
|
) -> BuzzPushNotificationResolver {
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [URLProtocolStub.self]
|
|
return BuzzPushNotificationResolver(
|
|
session: URLSession(configuration: configuration),
|
|
loadCommunitiesData: { communitiesData },
|
|
loadPrivateKey: { privateKeys[$0] },
|
|
notificationCache: cacheOverride ?? makeNotificationCache(communitiesData: communitiesData, fixture: presentationCacheData, now: now),
|
|
now: { now },
|
|
makeRetrySession: makeRetrySession
|
|
)
|
|
}
|
|
|
|
private func makeNotificationCache(communitiesData: Data?, fixture: Data?, now: Date) -> BuzzNotificationCache {
|
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
|
let communities = communitiesData.flatMap { try? JSONDecoder().decode(PushLeaseSnapshot.self, from: $0) }?.communities ?? []
|
|
let scopes = communities.compactMap(BuzzNotificationCacheScope.init)
|
|
let cache = BuzzNotificationCache(directory: root, now: { now }, isCurrent: { scopes.contains($0) })
|
|
let snapshot = BuzzPushPresentationCacheSnapshot.decode(fixture)
|
|
do {
|
|
for profile in snapshot.profiles {
|
|
let scope = try XCTUnwrap(scopes.first { $0.communityID == profile.communityID })
|
|
var content = ["display_name": profile.displayName ?? ""]
|
|
if profile.avatarPNG != nil { content["picture"] = "https://images.example/avatar.png" }
|
|
let event = try Self.signedEvent(privateKey: Self.profilePrivateKey,
|
|
createdAt: profile.eventCreatedAt, kind: 0,
|
|
content: String(decoding: JSONEncoder().encode(content), as: UTF8.self))
|
|
let writer = BuzzNotificationCache(directory: root,
|
|
now: { Date(timeIntervalSince1970: Double(profile.cachedAt)) }, isCurrent: { scopes.contains($0) })
|
|
try writer.save(event: event, scope: scope)
|
|
if let image = profile.avatarPNG {
|
|
let requestID = try XCTUnwrap(writer.beginAvatarRequest(
|
|
profileID: event.id, pubkey: event.pubkey, scope: scope))
|
|
try writer.saveAvatar(image, source: "https://images.example/avatar.png",
|
|
profileID: event.id, requestID: requestID, pubkey: event.pubkey, scope: scope)
|
|
}
|
|
}
|
|
for channel in snapshot.channels {
|
|
let scope = try XCTUnwrap(scopes.first { $0.communityID == channel.communityID })
|
|
let event = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
|
createdAt: channel.eventCreatedAt, kind: 39000,
|
|
tags: [["d", channel.channelID], ["name", channel.displayName ?? ""], ["t", channel.channelType ?? "stream"]])
|
|
let writer = BuzzNotificationCache(directory: root,
|
|
now: { Date(timeIntervalSince1970: Double(channel.cachedAt)) }, isCurrent: { scopes.contains($0) })
|
|
try writer.save(event: event, scope: scope)
|
|
guard let digests = channel.memberDigests else { continue }
|
|
let candidates = [Self.ownPubkey, try Self.pubkey(for: Self.profilePrivateKey), try Self.pubkey(for: Self.relayPrivateKey)]
|
|
let members = candidates.filter { candidate in
|
|
Self.memberDigests([candidate]).contains { digests.contains($0) }
|
|
}
|
|
let membership = try Self.signedEvent(privateKey: Self.relayPrivateKey,
|
|
createdAt: channel.membershipEventCreatedAt ?? channel.eventCreatedAt, kind: 39002,
|
|
tags: [["d", channel.channelID]] + members.map { ["p", $0] })
|
|
let membershipWriter = BuzzNotificationCache(directory: root,
|
|
now: { Date(timeIntervalSince1970: Double(channel.membershipCachedAt ?? channel.cachedAt)) }, isCurrent: { scopes.contains($0) })
|
|
try membershipWriter.save(event: membership, scope: scope)
|
|
}
|
|
} catch { XCTFail("Cannot seed verified cache: \(error)") }
|
|
return cache
|
|
}
|
|
|
|
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]
|
|
)
|
|
)
|
|
],
|
|
notificationSessionID: "test-session"
|
|
)
|
|
}
|
|
|
|
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 = bytes
|
|
return VerifiedNostrEvent.hex(try NostrSchnorr.publicKey(secret: key))
|
|
}
|
|
|
|
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 = privateKeyBytes
|
|
let pubkey = VerifiedNostrEvent.hex(try NostrSchnorr.publicKey(secret: key))
|
|
let serialization = try VerifiedNostrEvent.canonicalSerialization(
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: kind,
|
|
tags: tags,
|
|
content: content
|
|
)
|
|
let digest = Array(SHA256.hash(data: serialization))
|
|
let message = digest
|
|
let randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32)
|
|
let signature = try NostrSchnorr.sign(message: message, secret: key, auxiliaryRandomness: randomness)
|
|
return VerifiedNostrEvent(
|
|
id: VerifiedNostrEvent.hex(digest),
|
|
pubkey: pubkey,
|
|
createdAt: createdAt,
|
|
kind: kind,
|
|
tags: tags,
|
|
content: content,
|
|
sig: VerifiedNostrEvent.hex(signature)
|
|
)
|
|
}
|
|
|
|
final class URLProtocolStub: URLProtocol, @unchecked Sendable {
|
|
static let lock = NSLock()
|
|
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
|
static var finishLoading: ((URLRequest) -> Bool)?
|
|
static var onStopLoading: ((URLRequest) -> Void)?
|
|
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)
|
|
if Self.finishLoading?(request) ?? true {
|
|
client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
} catch {
|
|
client?.urlProtocol(self, didFailWithError: error)
|
|
}
|
|
}
|
|
|
|
override func stopLoading() { Self.onStopLoading?(request) }
|
|
|
|
static func reset() {
|
|
lock.lock()
|
|
handler = nil
|
|
finishLoading = nil
|
|
onStopLoading = nil
|
|
requests = []
|
|
lock.unlock()
|
|
}
|
|
}
|
|
}
|