Files
buzz/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift
Tom Brow 76f7e5ef0c feat(push): make gateway origin configurable (#7261)
Make the push gateway origin configurable across the relay, gateway,
mobile client, and deployment chart.

This is a fresh deployment. The gateway has only run on personal
development infrastructure, and push has no existing enabled users.
Legacy migration, configured-to-unconfigured mobile transitions, and
rolling upgrades from pre-launch gateway binaries are not supported.

Populated pre-launch databases are rejected with an informative error
before migrations run; their data is untouched. Mobile builds may omit
the gateway URL, leaving push unavailable. Once push is enabled,
subsequent builds must retain that configuration.

### Validation

- All applicable CI checks pass at
`5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust,
mobile/Swift, desktop/integration, security, image builds, and Helm
checks. An unrelated agent cancellation test passed on retry.
- PostgreSQL CI passed 384 tests, including the production-entry-point
regression for refusing populated pre-launch databases before
migrations, fresh initialization, and subsequent initialized
deployments.
- Focused local validation passed all 10 gateway PostgreSQL tests under
the CI discovery filter and all 3 unconfigured-mobile tests.
Repository-wide PostgreSQL discovery validation and the complete
pre-push hooks passed.
- Physical-device APNs/App Attest behavior and deployment to a live
cluster were not verified in this work.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 14:56:45 -07:00

477 lines
15 KiB
Swift

import BuzzPushKit
import Flutter
import Intents
import UserNotifications
import XCTest
@testable import Buzz
final class BuzzCommunicationNotificationTests: XCTestCase {
func testIntentUsesVerifiedSenderAvatarAndGroupName() throws {
let resolution = communicationResolution(
displayName: "Alice",
groupName: "General",
avatarPNG: Data([0x89, 0x50, 0x4E, 0x47])
)
let descriptor = try XCTUnwrap(
BuzzCommunicationNotificationDescriptor(resolution: resolution)
)
let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor)
XCTAssertEqual(intent.sender?.displayName, "Alice")
XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier)
XCTAssertNotNil(intent.sender?.image)
XCTAssertNotNil(intent.image(forParameterNamed: \.speakableGroupName))
XCTAssertEqual(intent.content, "Hello Buzz")
XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General")
XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier)
XCTAssertNil(intent.recipients)
XCTAssertEqual(descriptor.recipientCount, 1)
XCTAssertEqual(
(intent.donationMetadata as? INSendMessageIntentDonationMetadata)?.recipientCount,
1
)
}
func testDirectMessageRetainsSenderAvatarWithoutGroupMetadata() throws {
let resolution = communicationResolution(
displayName: "Alice",
groupName: nil,
avatarPNG: Data([0x89, 0x50, 0x4E, 0x47])
)
let descriptor = try XCTUnwrap(
BuzzCommunicationNotificationDescriptor(resolution: resolution)
)
let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor)
XCTAssertEqual(intent.sender?.displayName, "Alice")
XCTAssertNotNil(intent.sender?.image)
XCTAssertNil(intent.image(forParameterNamed: \.speakableGroupName))
XCTAssertNil(intent.speakableGroupName)
XCTAssertNil(intent.donationMetadata)
}
func testMissingVerifiedRecipientCountUsesOrdinaryPresentation() {
XCTAssertNil(
BuzzCommunicationNotificationDescriptor(
resolution: communicationResolution(recipientCount: nil)
)
)
}
func testPresentationFallsBackWhenDonationFails() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
ordinary.body = "Hello Buzz"
var updateCalled = false
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
completion(NSError(domain: "test", code: 1))
},
deleteAllInteractions: { completion in completion(nil) },
updateContent: { content, _ in
updateCalled = true
return content
}
)
let completed = expectation(description: "ordinary fallback returned")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution()
) { content in
XCTAssertEqual(content.title, "Alice")
XCTAssertEqual(content.body, "Hello Buzz")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertFalse(updateCalled)
}
func testPresentationDonatesBeforeSpecializing() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
var order: [String] = []
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
order.append("donate")
completion(nil)
},
deleteAllInteractions: { completion in completion(nil) },
updateContent: { _, _ in
order.append("update")
let specialized = UNMutableNotificationContent()
specialized.title = "specialized"
return specialized
}
)
let completed = expectation(description: "specialized content returned")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution()
) { content in
XCTAssertEqual(content.title, "specialized")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertEqual(order, ["donate", "update"])
}
func testPresentationFallsBackWhenSpecializationFails() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
ordinary.body = "Hello Buzz"
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in completion(nil) },
deleteAllInteractions: { completion in completion(nil) },
updateContent: { _, _ in throw NSError(domain: "test", code: 2) }
)
let completed = expectation(description: "ordinary fallback returned")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution()
) { content in
XCTAssertEqual(content.title, "Alice")
XCTAssertEqual(content.body, "Hello Buzz")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
}
func testFenceChangeDeletesDonatedInteractionBeforeCompleting() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
var allowed = true
var order: [String] = []
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
order.append("donate")
allowed = false
completion(nil)
},
deleteAllInteractions: { completion in
order.append("delete")
completion(nil)
},
updateContent: { content, _ in
order.append("update")
return content
}
)
let completed = expectation(description: "restricted donation deleted")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution(),
isStillAllowed: { allowed }
) { content in
order.append("complete")
XCTAssertEqual(content.title, "Alice")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertEqual(order, ["donate", "delete", "complete"])
}
func testBlockedFenceDoesNotDonate() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
var donateCalled = false
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
donateCalled = true
completion(nil)
},
deleteAllInteractions: { completion in completion(nil) },
updateContent: { content, _ in content }
)
let completed = expectation(description: "blocked donation skipped")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution(),
isStillAllowed: { false }
) { content in
XCTAssertEqual(content.title, "Alice")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertFalse(donateCalled)
}
func testFailedLateDonationDeletionReactivatesFenceUntilAppRetry() throws {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let fenceStore = BuzzAgeRestrictionFenceStore(containerURL: directory)
let fenceAtStart = fenceStore.current()
var order: [String] = []
let deletionFailure = NSError(domain: "test", code: 3)
let presenter = BuzzCommunicationNotificationPresenter(
donate: { _, completion in
order.append("donate")
do {
try fenceStore.performFencedCleanup {}
} catch {
XCTFail("Unable to prepare the donation cleanup: \(error)")
}
completion(nil)
},
deleteAllInteractions: { completion in
order.append("delete")
completion(deletionFailure)
},
updateContent: { content, _ in content }
)
let completed = expectation(description: "failed deletion fenced")
presenter.present(
ordinaryContent: ordinary,
resolution: communicationResolution(),
isStillAllowed: {
!fenceStore.current().requiresDiscard(since: fenceAtStart)
},
onDeletionFailure: { error in
XCTAssertEqual(error as NSError, deletionFailure)
do {
try fenceStore.begin()
} catch {
XCTFail("Unable to reactivate the restriction: \(error)")
}
order.append("fence")
}
) { content in
order.append("complete")
XCTAssertEqual(content.title, "Alice")
completed.fulfill()
}
wait(for: [completed], timeout: 1)
XCTAssertEqual(order, ["donate", "delete", "fence", "complete"])
XCTAssertTrue(fenceStore.current().isFencing)
var retryError: Error?
try fenceStore.performFencedAsyncCleanup(
{ $0(nil) },
completion: { retryError = $0 }
)
XCTAssertNil(retryError)
XCTAssertFalse(fenceStore.current().isFencing)
}
private func communicationResolution(
displayName: String = "Alice",
groupName: String? = "General",
avatarPNG: Data? = nil,
recipientCount: Int? = 1
) -> BuzzPushResolution {
let communityID = "community-id"
let channelID = "channel/general:v5"
return BuzzPushResolution(
title: displayName,
body: "Hello Buzz",
subtitle: "Community",
threadIdentifier: BuzzPushPresentationIdentity.conversation(
communityID: communityID,
channelID: channelID
),
navigationTarget: BuzzPushNavigationTarget(
eventID: "message-id",
communityID: communityID,
channelID: channelID
),
senderPubkey: String(repeating: "a", count: 64),
senderAvatarPNG: avatarPNG,
conversationIdentifier: BuzzPushPresentationIdentity.conversation(
communityID: communityID,
channelID: channelID
),
conversationDisplayName: groupName,
conversationRecipientCount: recipientCount
)
}
}
final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
func testLaunchBeginsAgeRestrictionFenceBeforeFlutterState() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
try BuzzAgeRestrictionFenceStore.beginLaunch(containerURL: directory) {
XCTFail("A successful fence must not remove credentials")
}
XCTAssertTrue(BuzzAgeRestrictionFenceStore(containerURL: directory).current().isFencing)
}
func testAgeSignalRequestRejectsFailedLaunchProtectionBeforeCheckingAge() {
let delegate = MissingAppGroupDelegate()
var completed = false
delegate.handleAgeSignalMethodCall(
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
viewController: nil
) { value in
XCTAssertEqual((value as? FlutterError)?.code, "age_signal_notification_protection_failed")
completed = true
}
XCTAssertTrue(completed)
}
func testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable() {
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil
)
let completed = expectation(description: "strict write rejected")
XCTAssertTrue(
bridge.handle(
FlutterMethodCall(
methodName: "syncAgeGatePushSnapshot",
arguments: [
"section": "communities",
"communities": [[String: Any]](),
"signingKeys": [String: String](),
"settleFence": false,
]
)
) { value in
XCTAssertEqual((value as? FlutterError)?.code, "snapshot_sync_unavailable")
completed.fulfill()
}
)
wait(for: [completed], timeout: 1)
}
func testMetadataAuthorityUsesCurrentAppProfileForMatchingRelay() {
let correctProfile = grant(
appProfile: BuzzDevPushEnrollmentDriver.appProfile,
generation: 2,
metadataPubkey: String(repeating: "a", count: 64)
)
let wrongProfile = grant(
appProfile: "other-profile",
generation: 99,
metadataPubkey: String(repeating: "b", count: 64)
)
XCTAssertEqual(
BuzzPushSnapshotBridge.relayMetadataPubkey(
relayURL: "wss://relay.example/",
grants: [wrongProfile, correctProfile]
),
correctProfile.relayMetadataPubkey
)
}
private func grant(
appProfile: String,
generation: Int64,
metadataPubkey: String
) -> BuzzPushEndpointGrantRecord {
BuzzPushEndpointGrantRecord(
gatewayOrigin: "https://push.example",
relayOrigin: "https://relay.example",
relayPubkey: String(repeating: "c", count: 64),
relayMetadataPubkey: metadataPubkey,
appAttestKeyId: Data(repeating: 0xAA, count: 32).base64EncodedString(),
installationId: "installation",
endpointGrant: "opaque-grant",
endpointHash: String(repeating: "d", count: 64),
appProfile: appProfile,
endpointEpoch: 1,
generation: generation,
expiresAt: 1_900_000_000
)
}
}
final class BuzzPushNotificationResponseTests: XCTestCase {
func testValidDefaultActionRoutesAndCompletesExactlyOnce() {
let target = BuzzPushNavigationTarget(
eventID: "message-id",
communityID: "community-id",
channelID: "opaque-channel-id"
)
var routedTargets: [BuzzPushNavigationTarget] = []
var forwarded = 0
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { pluginCompletion in
forwarded += 1
pluginCompletion()
pluginCompletion()
},
completion: { completions += 1 }
)
XCTAssertEqual(routedTargets, [target])
XCTAssertEqual(forwarded, 1)
XCTAssertEqual(completions, 1)
}
func testMalformedTargetFallsBackToOneCompletion() {
var routedTargets: [BuzzPushNavigationTarget] = []
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [BuzzPushNavigationTarget.userInfoKey: ["event_id": ""]],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { _ in },
completion: { completions += 1 }
)
XCTAssertTrue(routedTargets.isEmpty)
XCTAssertEqual(completions, 1)
}
func testNonDefaultActionIgnoresLateDuplicatePluginCompletion() throws {
var routedTargets: [BuzzPushNavigationTarget] = []
var pluginCompletion: (() -> Void)?
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: "buzz.reply",
userInfo: [:],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { pluginCompletion = $0 },
completion: { completions += 1 }
)
let capturedCompletion = try XCTUnwrap(pluginCompletion)
capturedCompletion()
capturedCompletion()
XCTAssertTrue(routedTargets.isEmpty)
XCTAssertEqual(completions, 1)
}
}
private final class MissingAppGroupDelegate: AppDelegate {
override var appGroupIdentifier: String? { nil }
}