mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
fix(mobile): treat push target IDs as opaque
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -10,9 +10,9 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
|
||||
public let channelID: String
|
||||
|
||||
public init(eventID: String, communityID: String, channelID: String) {
|
||||
self.eventID = eventID.lowercased()
|
||||
self.eventID = eventID
|
||||
self.communityID = communityID
|
||||
self.channelID = channelID.lowercased()
|
||||
self.channelID = channelID
|
||||
}
|
||||
|
||||
public var userInfoValue: [String: String] {
|
||||
@@ -32,9 +32,9 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
|
||||
let eventID = raw["event_id"] as? String,
|
||||
let communityID = raw["community_id"] as? String,
|
||||
let channelID = raw["channel_id"] as? String,
|
||||
!eventID.isEmpty,
|
||||
!communityID.isEmpty,
|
||||
isLowercaseHex64(eventID.lowercased()),
|
||||
isChannelID(channelID.lowercased())
|
||||
!channelID.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
@@ -45,24 +45,6 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
private static func isLowercaseHex64(_ value: String) -> Bool {
|
||||
value.utf8.count == 64
|
||||
&& value.utf8.allSatisfy {
|
||||
($0 >= 48 && $0 <= 57) || ($0 >= 97 && $0 <= 102)
|
||||
}
|
||||
}
|
||||
|
||||
private static func isChannelID(_ value: String) -> Bool {
|
||||
let bytes = Array(value.utf8)
|
||||
guard bytes.count == 36,
|
||||
bytes[8] == 45, bytes[13] == 45, bytes[18] == 45, bytes[23] == 45,
|
||||
[56, 57, 97, 98].contains(bytes[19])
|
||||
else { return false }
|
||||
return bytes.enumerated().allSatisfy { index, byte in
|
||||
if [8, 13, 18, 23].contains(index) { return byte == 45 }
|
||||
return (byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe one-item buffer spanning notification delivery and Flutter
|
||||
|
||||
@@ -3,11 +3,11 @@ import Testing
|
||||
|
||||
@testable import BuzzPushKit
|
||||
|
||||
@Test func `Round-trip UUIDv4 navigation target through notification user info`() {
|
||||
@Test func `Round-trip opaque navigation target through notification user info`() {
|
||||
let target = BuzzPushNavigationTarget(
|
||||
eventID: String(repeating: "A", count: 64),
|
||||
eventID: "MESSAGE-ID",
|
||||
communityID: "community-id",
|
||||
channelID: "123E4567-E89B-42D3-A456-426614174000"
|
||||
channelID: "CHANNEL/GENERAL"
|
||||
)
|
||||
|
||||
#expect(
|
||||
@@ -15,23 +15,8 @@ import Testing
|
||||
from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue]
|
||||
) == target
|
||||
)
|
||||
#expect(target.eventID == String(repeating: "a", count: 64))
|
||||
#expect(target.channelID == "123e4567-e89b-42d3-a456-426614174000")
|
||||
}
|
||||
|
||||
@Test func `Decode UUIDv5 navigation target`() {
|
||||
let target = BuzzPushNavigationTarget(
|
||||
eventID: String(repeating: "B", count: 64),
|
||||
communityID: "community-id",
|
||||
channelID: "9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50"
|
||||
)
|
||||
|
||||
#expect(
|
||||
BuzzPushNavigationTarget.decodeIfPresent(
|
||||
from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue]
|
||||
) == target
|
||||
)
|
||||
#expect(target.channelID == "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50")
|
||||
#expect(target.eventID == "MESSAGE-ID")
|
||||
#expect(target.channelID == "CHANNEL/GENERAL")
|
||||
}
|
||||
|
||||
@Test func `Reject incomplete or malformed navigation target`() {
|
||||
@@ -39,7 +24,7 @@ import Testing
|
||||
BuzzPushNavigationTarget.decodeIfPresent(
|
||||
from: [
|
||||
BuzzPushNavigationTarget.userInfoKey: [
|
||||
"event_id": "event-id",
|
||||
"event_id": "message-id",
|
||||
"community_id": "community-id",
|
||||
]
|
||||
]
|
||||
@@ -50,9 +35,9 @@ import Testing
|
||||
BuzzPushNavigationTarget.decodeIfPresent(
|
||||
from: [
|
||||
BuzzPushNavigationTarget.userInfoKey: [
|
||||
"event_id": String(repeating: "a", count: 64),
|
||||
"event_id": "",
|
||||
"community_id": "community-id",
|
||||
"channel_id": "not-a-channel",
|
||||
"channel_id": "channel-id",
|
||||
]
|
||||
]
|
||||
) == nil
|
||||
@@ -62,9 +47,9 @@ import Testing
|
||||
BuzzPushNavigationTarget.decodeIfPresent(
|
||||
from: [
|
||||
BuzzPushNavigationTarget.userInfoKey: [
|
||||
"event_id": String(repeating: "a", count: 64),
|
||||
"event_id": "message-id",
|
||||
"community_id": "community-id",
|
||||
"channel_id": "9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50",
|
||||
"channel_id": "",
|
||||
]
|
||||
]
|
||||
) == nil
|
||||
|
||||
@@ -24,32 +24,23 @@ final pushEndpointGrantError = ValueNotifier<String?>(null);
|
||||
/// notifier also carries warm responses into the existing deep-link pipeline.
|
||||
final pendingPushNotificationLink = ValueNotifier<MessageDeepLink?>(null);
|
||||
|
||||
final _pushEventIdPattern = RegExp(r'^[0-9a-f]{64}$');
|
||||
final _pushChannelIdPattern = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
);
|
||||
|
||||
MessageDeepLink? _pushNotificationLink(Object? arguments) {
|
||||
if (arguments is! Map) return null;
|
||||
final eventId = arguments['eventId'];
|
||||
final communityId = arguments['communityId'];
|
||||
final channelId = arguments['channelId'];
|
||||
final normalizedEventId = eventId is String ? eventId.toLowerCase() : '';
|
||||
final normalizedChannelId = channelId is String
|
||||
? channelId.toLowerCase()
|
||||
: '';
|
||||
if (eventId is! String ||
|
||||
!_pushEventIdPattern.hasMatch(normalizedEventId) ||
|
||||
eventId.isEmpty ||
|
||||
communityId is! String ||
|
||||
communityId.isEmpty ||
|
||||
channelId is! String ||
|
||||
!_pushChannelIdPattern.hasMatch(normalizedChannelId)) {
|
||||
channelId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return MessageDeepLink(
|
||||
communityId: communityId,
|
||||
channelId: normalizedChannelId,
|
||||
messageId: normalizedEventId,
|
||||
channelId: channelId,
|
||||
messageId: eventId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -150,16 +150,16 @@ void main() {
|
||||
expect(apnsRegistrationError.value, 'denied');
|
||||
});
|
||||
|
||||
test('routes a warm notification response with a UUIDv4 channel', () async {
|
||||
test('routes a warm notification response with opaque IDs', () async {
|
||||
final response = Completer<ByteData?>();
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
_channel.name,
|
||||
_channel.codec.encodeMethodCall(
|
||||
MethodCall('notificationOpened', {
|
||||
'eventId': 'a' * 64,
|
||||
'eventId': 'MESSAGE-ID',
|
||||
'communityId': 'community-id',
|
||||
'channelId': '123e4567-e89b-42d3-a456-426614174000',
|
||||
'channelId': 'CHANNEL/GENERAL',
|
||||
}),
|
||||
),
|
||||
response.complete,
|
||||
@@ -172,22 +172,22 @@ void main() {
|
||||
pendingPushNotificationLink.value,
|
||||
MessageDeepLink(
|
||||
communityId: 'community-id',
|
||||
channelId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
messageId: 'a' * 64,
|
||||
channelId: 'CHANNEL/GENERAL',
|
||||
messageId: 'MESSAGE-ID',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('routes a warm notification response with a UUIDv5 channel', () async {
|
||||
test('rejects a notification response with an empty channel ID', () async {
|
||||
final response = Completer<ByteData?>();
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
_channel.name,
|
||||
_channel.codec.encodeMethodCall(
|
||||
MethodCall('notificationOpened', {
|
||||
'eventId': 'b' * 64,
|
||||
'eventId': 'message-id',
|
||||
'communityId': 'community-id',
|
||||
'channelId': '9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50',
|
||||
'channelId': '',
|
||||
}),
|
||||
),
|
||||
response.complete,
|
||||
@@ -195,19 +195,12 @@ void main() {
|
||||
|
||||
final envelope = await response.future;
|
||||
expect(envelope, isNotNull);
|
||||
expect(_channel.codec.decodeEnvelope(envelope!), 'handled');
|
||||
expect(
|
||||
pendingPushNotificationLink.value,
|
||||
MessageDeepLink(
|
||||
communityId: 'community-id',
|
||||
channelId: '9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50',
|
||||
messageId: 'b' * 64,
|
||||
),
|
||||
);
|
||||
expect(_channel.codec.decodeEnvelope(envelope!), 'ignored');
|
||||
expect(pendingPushNotificationLink.value, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'rejects a notification response with a malformed channel UUID',
|
||||
'rejects a notification response with a non-string message ID',
|
||||
() async {
|
||||
final response = Completer<ByteData?>();
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
@@ -215,9 +208,9 @@ void main() {
|
||||
_channel.name,
|
||||
_channel.codec.encodeMethodCall(
|
||||
MethodCall('notificationOpened', {
|
||||
'eventId': 'c' * 64,
|
||||
'eventId': 42,
|
||||
'communityId': 'community-id',
|
||||
'channelId': '9a1657ac-f7aa-5db0-7632-d8bbeb6dfb50',
|
||||
'channelId': 'channel-id',
|
||||
}),
|
||||
),
|
||||
response.complete,
|
||||
@@ -230,6 +223,26 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('rejects a notification response with an absent message ID', () async {
|
||||
final response = Completer<ByteData?>();
|
||||
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.handlePlatformMessage(
|
||||
_channel.name,
|
||||
_channel.codec.encodeMethodCall(
|
||||
const MethodCall('notificationOpened', {
|
||||
'communityId': 'community-id',
|
||||
'channelId': 'channel-id',
|
||||
}),
|
||||
),
|
||||
response.complete,
|
||||
);
|
||||
|
||||
final envelope = await response.future;
|
||||
expect(envelope, isNotNull);
|
||||
expect(_channel.codec.decodeEnvelope(envelope!), 'ignored');
|
||||
expect(pendingPushNotificationLink.value, isNull);
|
||||
});
|
||||
|
||||
test('pulls a cold-start notification response from native iOS', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
|
||||
Reference in New Issue
Block a user