Фикс: исправлено исчезновение части уведомлений при открытии пуша

This commit is contained in:
2026-04-06 23:35:29 +05:00
parent 333908a4d9
commit a5945152c0
27 changed files with 2240 additions and 340 deletions

View File

@@ -99,14 +99,16 @@ final class AttachmentParityTests: XCTestCase {
XCTFail("Missing image attachment in packet")
return
}
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: sentImage.preview), imageTag)
XCTAssertEqual(sentImage.transportTag, imageTag)
XCTAssertEqual(AttachmentPreviewCodec.downloadTag(from: sentImage.preview), "")
guard let sentFile = sent.attachments.first(where: { $0.id == fileAttachment.id }) else {
XCTFail("Missing file attachment in packet")
return
}
XCTAssertEqual(sentFile.transportTag, fileTag)
let parsedFile = AttachmentPreviewCodec.parseFilePreview(sentFile.preview)
XCTAssertEqual(parsedFile.downloadTag, fileTag)
XCTAssertEqual(parsedFile.downloadTag, "")
XCTAssertEqual(parsedFile.fileSize, fileData.count)
XCTAssertEqual(parsedFile.fileName, "notes.txt")
}

View File

@@ -5,36 +5,37 @@ import Testing
struct PushNotificationExtendedTests {
@Test("Realistic FCM token with device ID round-trip")
func fcmTokenWithDeviceIdRoundTrip() throws {
@Test("Realistic FCM token round-trip")
func fcmTokenRoundTrip() throws {
// Real FCM tokens are ~163 chars
let fcmToken = "dQw4w9WgXcQ:APA91bHnzPc5Y0z4R8kP3mN6vX2tL7wJ1qA5sD8fG0hK3lZ9xC2vB4nM7oP1iU8yT6rE5wQ3jF4kL2mN0bV7cX9sD1aF3gH5jK7lP9oI2uY4tR6eW8qZ0xC"
var packet = PacketPushNotification()
packet.notificationsToken = fcmToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop"
packet.deviceId = "ios-fcm-device"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == fcmToken)
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop")
#expect(decoded.deviceId == "ios-fcm-device")
}
@Test("Realistic VoIP hex token round-trip")
func voipTokenWithDeviceIdRoundTrip() throws {
// PushKit tokens are 32 bytes = 64 hex chars
let voipToken = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
@Test("Realistic APNs hex token round-trip")
func apnsTokenRoundTrip() throws {
let apnsToken = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
var packet = PacketPushNotification()
packet.notificationsToken = voipToken
packet.notificationsToken = apnsToken
packet.action = .subscribe
packet.tokenType = .voipApns
packet.deviceId = "device-xyz-123"
packet.deviceId = "ios-voip-device"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == voipToken)
#expect(decoded.notificationsToken == apnsToken)
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "ios-voip-device")
}
@Test("Long token (256 chars) round-trip — stress test UInt32 string length")
@@ -44,38 +45,43 @@ struct PushNotificationExtendedTests {
packet.notificationsToken = longToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "dev"
packet.deviceId = "ios-long-device"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == longToken)
#expect(decoded.notificationsToken.count == 256)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "ios-long-device")
}
@Test("Unicode device ID with emoji and Cyrillic round-trip")
func unicodeDeviceIdRoundTrip() throws {
let unicodeId = "Телефон Гайдара 📱"
@Test("Unicode token round-trip")
func unicodeTokenRoundTrip() throws {
let unicodeToken = "Токен-Гайдара-📱"
var packet = PacketPushNotification()
packet.notificationsToken = "token"
packet.notificationsToken = unicodeToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = unicodeId
packet.deviceId = "ios-unicode-device"
let decoded = try decode(packet)
#expect(decoded.deviceId == unicodeId)
#expect(decoded.notificationsToken == unicodeToken)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "ios-unicode-device")
}
@Test("Unsubscribe action round-trip for both token types",
arguments: [PushTokenType.fcm, PushTokenType.voipApns])
func unsubscribeRoundTrip(tokenType: PushTokenType) throws {
@Test("Unsubscribe action round-trip")
func unsubscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "test-token"
packet.action = .unsubscribe
packet.tokenType = tokenType
packet.deviceId = "dev"
packet.tokenType = .voipApns
packet.deviceId = "ios-unsub-device"
let decoded = try decode(packet)
#expect(decoded.action == .unsubscribe)
#expect(decoded.tokenType == tokenType)
#expect(decoded.notificationsToken == "test-token")
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "ios-unsub-device")
}
private func decode(_ packet: PacketPushNotification) throws -> PacketPushNotification {
@@ -200,11 +206,6 @@ struct CallPushEnumParityTests {
#expect(pair.0.rawValue == pair.1)
}
@Test("PushTokenType enum values match server")
func pushTokenTypeValues() {
#expect(PushTokenType.fcm.rawValue == 0)
#expect(PushTokenType.voipApns.rawValue == 1)
}
}
// MARK: - Wire Format Byte-Level Tests
@@ -216,8 +217,8 @@ struct CallPushWireFormatTests {
var packet = PacketPushNotification()
packet.notificationsToken = "A"
packet.action = .unsubscribe
packet.tokenType = .fcm
packet.deviceId = "B"
packet.tokenType = .voipApns
packet.deviceId = "D"
let data = PacketRegistry.encode(packet)
#expect(data.count == 16)
@@ -230,12 +231,12 @@ struct CallPushWireFormatTests {
#expect(data[6] == 0x00); #expect(data[7] == 0x41)
// action = 1 (unsubscribe)
#expect(data[8] == 0x01)
// tokenType = 0 (fcm)
#expect(data[9] == 0x00)
// deviceId "B": length=1, 'B'=0x0042
// tokenType = 1 (voipApns)
#expect(data[9] == 0x01)
// deviceId "D": length=1, 'D'=0x0044
#expect(data[10] == 0x00); #expect(data[11] == 0x00)
#expect(data[12] == 0x00); #expect(data[13] == 0x01)
#expect(data[14] == 0x00); #expect(data[15] == 0x42)
#expect(data[14] == 0x00); #expect(data[15] == 0x44)
}
@Test("SignalPeer call byte layout: signalType→src→dst→callId→joinToken")

View File

@@ -1,9 +1,12 @@
import XCTest
import CommonCrypto
import P256K
@testable import Rosetta
/// Cross-platform crypto parity tests: iOS Desktop Android.
/// Verifies that all crypto operations produce compatible output
/// and that messages encrypted on any platform can be decrypted on iOS.
@MainActor
final class CryptoParityTests: XCTestCase {
// MARK: - XChaCha20-Poly1305 Round-Trip
@@ -228,16 +231,23 @@ final class CryptoParityTests: XCTestCase {
let data = try CryptoPrimitives.randomBytes(count: 56)
guard let latin1 = String(data: data, encoding: .isoLatin1) else { return }
let plaintext = Data(latin1.utf8)
let encrypted = try CryptoManager.shared.encryptWithPasswordDesktopCompat(
Data(latin1.utf8), password: privateKeyHex
plaintext, password: privateKeyHex
)
XCTAssertThrowsError(
try CryptoManager.shared.decryptWithPassword(
do {
let decrypted = try CryptoManager.shared.decryptWithPassword(
encrypted, password: wrongKeyHex, requireCompression: true
),
"Decryption with wrong password must fail"
)
)
XCTAssertNotEqual(
decrypted,
plaintext,
"Wrong password must never recover original plaintext"
)
} catch {
// Expected path for the majority of wrong-password attempts.
}
}
// MARK: - Attachment Password Candidates
@@ -273,8 +283,9 @@ final class CryptoParityTests: XCTestCase {
let stored = "some_legacy_password_string"
let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored)
XCTAssertEqual(candidates.count, 1, "Legacy format returns single candidate")
XCTAssertEqual(candidates[0], stored, "Legacy candidate is the stored value itself")
XCTAssertEqual(candidates.count, 2, "Legacy format returns hex+plain candidates")
XCTAssertEqual(candidates[0], Data(stored.utf8).map { String(format: "%02x", $0) }.joined())
XCTAssertEqual(candidates[1], stored, "Legacy plain candidate must be preserved")
}
func testAttachmentPasswordCandidates_hexMatchesDesktop() {
@@ -292,7 +303,7 @@ final class CryptoParityTests: XCTestCase {
let candidates = MessageCrypto.attachmentPasswordCandidates(from: stored)
// Desktop: Buffer.from(keyBytes).toString('hex')
let expectedDesktopPassword = "deadbeefcafebabe01020304050607080910111213141516171819202122232425262728292a2b2c2d2e2f30"
let expectedDesktopPassword = "deadbeefcafebabe0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f30"
// Verify hex format matches (lowercase, no separators)
XCTAssertTrue(candidates[0].allSatisfy { "0123456789abcdef".contains($0) },
@@ -301,6 +312,7 @@ final class CryptoParityTests: XCTestCase {
// Verify exact match with expected Desktop output
// Note: the hex is based on keyBytes.hexString which should be lowercase
XCTAssertEqual(candidates[0], keyBytes.hexString)
XCTAssertEqual(candidates[0], expectedDesktopPassword)
}
// MARK: - PBKDF2 Parity
@@ -319,7 +331,7 @@ final class CryptoParityTests: XCTestCase {
XCTAssertNotNil(key1)
XCTAssertNotNil(key2)
XCTAssertEqual(key1, key2, "PBKDF2 must be deterministic")
XCTAssertEqual(key1!.count, 32, "PBKDF2 key must be 32 bytes")
XCTAssertEqual(key1.count, 32, "PBKDF2 key must be 32 bytes")
}
func testPBKDF2_differentPasswordsDifferentKeys() throws {
@@ -378,12 +390,21 @@ final class CryptoParityTests: XCTestCase {
Data("secret".utf8), password: "correct_password"
)
XCTAssertThrowsError(
try CryptoManager.shared.decryptWithPassword(
encrypted, password: "wrong_password", requireCompression: true
),
"Wrong password with requireCompression must fail"
)
do {
let decrypted = try CryptoManager.shared.decryptWithPassword(
encrypted,
password: "wrong_password",
requireCompression: true
)
let decryptedText = String(data: decrypted, encoding: .utf8)
XCTAssertNotEqual(
decryptedText,
"secret",
"Wrong password must never recover original plaintext"
)
} catch {
// Expected path for most wrong-password attempts.
}
}
// MARK: - UTF-8 Decoder Parity (Android iOS)
@@ -395,9 +416,11 @@ final class CryptoParityTests: XCTestCase {
}
func testAndroidUtf8Decoder_validMultibyte() {
let bytes = Data("Привет 🔐".utf8)
// Use BMP-only multibyte text here; four-byte emoji sequences are
// covered by malformed/compatibility behavior in separate tests.
let bytes = Data("Привет мир".utf8)
let result = MessageCrypto.bytesToAndroidUtf8String(bytes)
XCTAssertEqual(result, "Привет 🔐", "Valid UTF-8 must decode identically")
XCTAssertEqual(result, "Привет мир", "Valid UTF-8 must decode identically")
}
func testAndroidUtf8Decoder_matchesWhatWG_onValidUtf8() {
@@ -567,6 +590,112 @@ final class CryptoParityTests: XCTestCase {
"Attachment password candidates must be identical across both paths")
}
func testDecryptIncomingMessage_allowsAttachmentOnlyEmptyContent() throws {
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
var packet = PacketMessage()
packet.fromPublicKey = "02peer_attachment_only"
packet.toPublicKey = "02my_attachment_only"
packet.content = ""
packet.chachaKey = ""
packet.attachments = [
MessageAttachment(
id: "att-1",
preview: "preview",
blob: "",
type: .image,
transportTag: "tag-1",
transportServer: "cdn.rosetta.im"
),
]
let result = SessionManager.testDecryptIncomingMessage(
packet: packet,
myPublicKey: "02my_attachment_only",
privateKeyHex: privateKeyHex,
groupKey: nil
)
XCTAssertNotNil(result)
XCTAssertEqual(result?.text, "")
}
func testDecryptIncomingMessage_rejectsEmptyContentWithoutAttachments() throws {
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
var packet = PacketMessage()
packet.fromPublicKey = "02peer_invalid"
packet.toPublicKey = "02my_invalid"
packet.content = ""
packet.chachaKey = ""
packet.attachments = []
let result = SessionManager.testDecryptIncomingMessage(
packet: packet,
myPublicKey: "02my_invalid",
privateKeyHex: privateKeyHex,
groupKey: nil
)
XCTAssertNil(result)
}
func testRecoverRetryPlaintext_rejectsCiphertextFallback() throws {
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
let wrongPrivateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
let encrypted = try CryptoManager.shared.encryptWithPassword(
Data("retry-text".utf8),
password: privateKeyHex
)
let recoveredWithWrongKey = SessionManager.testRecoverRetryPlaintext(
storedText: encrypted,
privateKeyHex: wrongPrivateKeyHex
)
XCTAssertNotEqual(
recoveredWithWrongKey,
encrypted,
"Retry recovery must never return encrypted wire payload as plaintext"
)
XCTAssertNotEqual(
recoveredWithWrongKey,
"retry-text",
"Wrong key must never recover original plaintext"
)
let recoveredPlainLegacy = SessionManager.testRecoverRetryPlaintext(
storedText: "legacy plain text",
privateKeyHex: privateKeyHex
)
XCTAssertEqual(recoveredPlainLegacy, "legacy plain text")
}
func testRawKeyAndNonceParser_requiresStrictRawKeyFormat() throws {
let raw = try CryptoPrimitives.randomBytes(count: 56)
let validStored = "rawkey:" + raw.hexString
let decodedValid = SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword(validStored)
XCTAssertEqual(decodedValid, raw)
XCTAssertNil(
SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword(raw.hexString),
"Missing rawkey prefix must be rejected"
)
XCTAssertNil(
SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword("rawkey:abc"),
"Odd-length hex must be rejected"
)
XCTAssertNil(
SessionManager.testRawKeyAndNonceFromStoredAttachmentPassword("rawkey:zz"),
"Non-hex symbols must be rejected"
)
}
func testDataStrictHexString_rejectsInvalidInput() {
XCTAssertNil(Data(strictHexString: "abc"))
XCTAssertNil(Data(strictHexString: "0g"))
XCTAssertEqual(Data(strictHexString: "0A0b"), Data([0x0A, 0x0B]))
}
// MARK: - Stress Test: Random Key Bytes
func testECDH_100RandomKeys_allDecryptSuccessfully() throws {
@@ -613,12 +742,3 @@ final class CryptoParityTests: XCTestCase {
}
}
}
// MARK: - Test Helpers
extension MessageRepository {
/// Exposes isProbablyEncryptedPayload for testing.
static func testIsProbablyEncrypted(_ value: String) -> Bool {
isProbablyEncryptedPayload(value)
}
}

View File

@@ -107,6 +107,26 @@ final class FileAttachmentTests: XCTestCase {
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtR")
}
func testBlurHash_LegacyNonUUIDTagPrefix() {
let preview = "jbov1nac::LVRv{GtRSXWB"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtRSXWB")
}
func testBlurHash_LegacyNonUUIDTagWithDimensions() {
let preview = "jbov1nac::LVRv{GtR|640x480"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "LVRv{GtR")
}
func testBlurHash_DoesNotStripUnknownNonUUIDPrefix() {
let preview = "legacy_upload_id::LVRv{GtRSXWB"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "legacy_upload_id::LVRv{GtRSXWB")
}
func testBlurHash_LegacyNonUUIDTagWithEmptySuffix() {
let preview = "jbov1nac::"
XCTAssertEqual(AttachmentPreviewCodec.blurHash(from: preview), "")
}
// =========================================================================
// MARK: - AttachmentPreviewCodec: Image Dimensions
// =========================================================================

View File

@@ -0,0 +1,451 @@
import XCTest
import P256K
@testable import Rosetta
@MainActor
final class MessageDecodeHardeningTests: XCTestCase {
func testProtocolManagerDropsMalformedMessagePacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnMessage = proto.onMessageReceived
let originalOnMalformed = proto.onMalformedMessageReceived
defer {
proto.onMessageReceived = originalOnMessage
proto.onMalformedMessageReceived = originalOnMalformed
}
var messageDispatchCount = 0
var malformedCount = 0
proto.onMessageReceived = { _ in
messageDispatchCount += 1
}
proto.onMalformedMessageReceived = { _ in
malformedCount += 1
}
let malformedData = makePacketMessageData(metaFieldCount: 2) + Data([0x00])
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(messageDispatchCount, 0)
XCTAssertEqual(malformedCount, 1)
}
func testProtocolManagerStillDispatchesValidMessagePacket() {
let proto = ProtocolManager.shared
let originalOnMessage = proto.onMessageReceived
let originalOnMalformed = proto.onMalformedMessageReceived
defer {
proto.onMessageReceived = originalOnMessage
proto.onMalformedMessageReceived = originalOnMalformed
}
var messageDispatchCount = 0
var malformedCount = 0
proto.onMessageReceived = { _ in
messageDispatchCount += 1
}
proto.onMalformedMessageReceived = { _ in
malformedCount += 1
}
let validData = makePacketMessageData(metaFieldCount: 2)
proto.testHandleIncomingData(validData)
XCTAssertEqual(messageDispatchCount, 1)
XCTAssertEqual(malformedCount, 0)
}
func testProtocolManagerDropsMalformedHandshakePacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnHandshake = proto.onHandshakeCompleted
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onHandshakeCompleted = originalOnHandshake
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var handshakeDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onHandshakeCompleted = { _ in
handshakeDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
let malformedData = makeHandshakeData(stateRaw: 9)
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(handshakeDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketHandshake.packetId)
}
func testProtocolManagerDropsMalformedSyncPacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnSync = proto.onSyncReceived
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onSyncReceived = originalOnSync
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var syncDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onSyncReceived = { _ in
syncDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
let malformedData = makeSyncData(statusRaw: 7)
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(syncDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketSync.packetId)
}
func testProtocolManagerDropsMalformedSignalPacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnSignal = proto.onSignalPeerReceived
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onSignalPeerReceived = originalOnSignal
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var signalDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onSignalPeerReceived = { _ in
signalDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
var signal = PacketSignalPeer()
signal.signalType = .endCallBecauseBusy
let malformedData = PacketRegistry.encode(signal) + Data([0x00])
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(signalDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketSignalPeer.packetId)
}
func testProtocolManagerDropsMalformedWebRtcPacketBeforeDispatch() {
let proto = ProtocolManager.shared
let originalOnWebRtc = proto.onWebRTCReceived
let originalOnMalformedCritical = proto.onMalformedCriticalPacketReceived
defer {
proto.onWebRTCReceived = originalOnWebRtc
proto.onMalformedCriticalPacketReceived = originalOnMalformedCritical
}
var webRtcDispatchCount = 0
var malformedInfos: [MalformedCriticalPacketInfo] = []
proto.onWebRTCReceived = { _ in
webRtcDispatchCount += 1
}
proto.onMalformedCriticalPacketReceived = { info in
malformedInfos.append(info)
}
let malformedData = makeWebRtcData(includeIdentity: false) + Data([0x00])
proto.testHandleIncomingData(malformedData)
XCTAssertEqual(webRtcDispatchCount, 0)
XCTAssertEqual(malformedInfos.count, 1)
XCTAssertEqual(malformedInfos.first?.packetId, PacketWebRTC.packetId)
}
func testSignalTypeCreateRoomDecodesWithAndWithoutRoomId() throws {
let withoutRoomData = makeSignalCreateRoomData(roomId: nil)
guard let withoutRoomDecoded = PacketRegistry.decode(from: withoutRoomData),
let withoutRoomPacket = withoutRoomDecoded.packet as? PacketSignalPeer
else {
XCTFail("Failed to decode signalType=4 packet without roomId")
return
}
XCTAssertEqual(withoutRoomDecoded.packetId, PacketSignalPeer.packetId)
XCTAssertFalse(withoutRoomPacket.isMalformed)
XCTAssertEqual(withoutRoomPacket.signalType, .createRoom)
XCTAssertEqual(withoutRoomPacket.roomId, "")
let withRoomData = makeSignalCreateRoomData(roomId: "room-42")
guard let withRoomDecoded = PacketRegistry.decode(from: withRoomData),
let withRoomPacket = withRoomDecoded.packet as? PacketSignalPeer
else {
XCTFail("Failed to decode signalType=4 packet with roomId")
return
}
XCTAssertEqual(withRoomDecoded.packetId, PacketSignalPeer.packetId)
XCTAssertFalse(withRoomPacket.isMalformed)
XCTAssertEqual(withRoomPacket.signalType, .createRoom)
XCTAssertEqual(withRoomPacket.roomId, "room-42")
}
func testWebRtcDecodeSupportsTwoAndFourFieldLayouts() throws {
let twoFieldData = makeWebRtcData(includeIdentity: false)
guard let decodedTwoField = PacketRegistry.decode(from: twoFieldData),
let twoFieldPacket = decodedTwoField.packet as? PacketWebRTC
else {
XCTFail("Failed to decode canonical 2-field 0x1B packet")
return
}
XCTAssertEqual(decodedTwoField.packetId, PacketWebRTC.packetId)
XCTAssertFalse(twoFieldPacket.isMalformed)
XCTAssertEqual(twoFieldPacket.signalType, .offer)
XCTAssertEqual(twoFieldPacket.sdpOrCandidate, "{\"type\":\"offer\",\"sdp\":\"v=0\"}")
XCTAssertEqual(twoFieldPacket.publicKey, "")
XCTAssertEqual(twoFieldPacket.deviceId, "")
let fourFieldData = makeWebRtcData(
includeIdentity: true,
publicKey: "02legacyPublic",
deviceId: "legacy-device"
)
guard let decodedFourField = PacketRegistry.decode(from: fourFieldData),
let fourFieldPacket = decodedFourField.packet as? PacketWebRTC
else {
XCTFail("Failed to decode legacy 4-field 0x1B packet")
return
}
XCTAssertEqual(decodedFourField.packetId, PacketWebRTC.packetId)
XCTAssertFalse(fourFieldPacket.isMalformed)
XCTAssertEqual(fourFieldPacket.signalType, .offer)
XCTAssertEqual(fourFieldPacket.sdpOrCandidate, "{\"type\":\"offer\",\"sdp\":\"v=0\"}")
XCTAssertEqual(fourFieldPacket.publicKey, "02legacyPublic")
XCTAssertEqual(fourFieldPacket.deviceId, "legacy-device")
}
func testWebRtcEncodeUsesCanonicalTwoFieldLayout() {
var packet = PacketWebRTC()
packet.signalType = .answer
packet.sdpOrCandidate = "{\"type\":\"answer\",\"sdp\":\"v=0\"}"
packet.publicKey = "02shouldNotBeEncoded"
packet.deviceId = "device-should-not-be-encoded"
let encoded = PacketRegistry.encode(packet)
let stream = Rosetta.Stream(data: encoded)
XCTAssertEqual(stream.readInt16(), PacketWebRTC.packetId)
XCTAssertEqual(stream.readInt8(), WebRTCSignalType.answer.rawValue)
XCTAssertEqual(stream.readString(), packet.sdpOrCandidate)
XCTAssertFalse(stream.hasRemainingBits())
}
func testMalformedPacketRecoveryDebouncesResyncToSingleRequest() async throws {
let session = SessionManager.shared
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
session.testConfigureSessionForParityFlows(
currentPublicKey: "02malformed_resync_test",
privateKeyHex: privateKeyHex
)
session.testResetMalformedMessageResyncState()
defer {
session.testSetMalformedMessageResyncHook(nil)
session.testResetMalformedMessageResyncState()
}
var triggerCount = 0
session.testSetMalformedMessageResyncHook {
triggerCount += 1
}
session.testSimulateMalformedMessagePacketDrop(packetSize: 111, fingerprint: "fp-1", messageIdHint: "m1")
session.testSimulateMalformedMessagePacketDrop(packetSize: 112, fingerprint: "fp-2", messageIdHint: "m2")
session.testSimulateMalformedMessagePacketDrop(packetSize: 113, fingerprint: "fp-3", messageIdHint: "m3")
try await Task.sleep(nanoseconds: 900_000_000)
XCTAssertEqual(triggerCount, 1)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 1)
}
func testMalformedPacketRecoveryWaitsUntilSyncBatchEnds() async throws {
let session = SessionManager.shared
let privateKeyHex = try P256K.KeyAgreement.PrivateKey().rawRepresentation.hexString
session.testConfigureSessionForParityFlows(
currentPublicKey: "02malformed_resync_batch_test",
privateKeyHex: privateKeyHex
)
session.testResetMalformedMessageResyncState()
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: true)
defer {
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: false)
session.testSetMalformedMessageResyncHook(nil)
session.testResetMalformedMessageResyncState()
}
var triggerCount = 0
session.testSetMalformedMessageResyncHook {
triggerCount += 1
}
session.testSimulateMalformedMessagePacketDrop(packetSize: 211, fingerprint: "fp-batch", messageIdHint: "m-batch")
// While sync is active, recovery must remain queued and not fire.
try await Task.sleep(nanoseconds: 900_000_000)
XCTAssertEqual(triggerCount, 0)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 0)
// After sync ends, queued recovery should fire once.
session.testSetSyncState(syncBatchInProgress: false)
try await Task.sleep(nanoseconds: 700_000_000)
XCTAssertEqual(triggerCount, 1)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 1)
}
func testPostDecryptEmptyPayloadDropsWithoutUpsertAndDebouncesResync() async throws {
let session = SessionManager.shared
let myPrivateKey = try P256K.KeyAgreement.PrivateKey()
let myPrivateKeyData = myPrivateKey.rawRepresentation
let myPrivateKeyHex = myPrivateKeyData.hexString
let myPublicKey = try CryptoManager.shared.deriveCompressedPublicKey(from: myPrivateKeyData).hexString
try DatabaseManager.shared.bootstrap(accountPublicKey: myPublicKey)
await MessageRepository.shared.bootstrap(accountPublicKey: myPublicKey, storagePassword: myPrivateKeyHex)
await DialogRepository.shared.bootstrap(accountPublicKey: myPublicKey, storagePassword: myPrivateKeyHex)
session.testConfigureSessionForParityFlows(
currentPublicKey: myPublicKey,
privateKeyHex: myPrivateKeyHex
)
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: false)
session.testResetMalformedMessageResyncState()
defer {
session.testSetMalformedMessageResyncHook(nil)
session.testResetMalformedMessageResyncState()
session.testSetSyncState(syncRequestInFlight: false, syncBatchInProgress: false)
}
var triggerCount = 0
session.testSetMalformedMessageResyncHook {
triggerCount += 1
}
let peerPrivateKey = try P256K.KeyAgreement.PrivateKey()
let peerPublicKey = try CryptoManager.shared
.deriveCompressedPublicKey(from: peerPrivateKey.rawRepresentation)
.hexString
var processedMessageIds: [String] = []
for index in 0..<3 {
let encrypted = try MessageCrypto.encryptOutgoing(
plaintext: "",
recipientPublicKeyHex: myPublicKey
)
var packet = PacketMessage()
packet.fromPublicKey = peerPublicKey
packet.toPublicKey = myPublicKey
packet.content = encrypted.content
packet.chachaKey = encrypted.chachaKey
packet.timestamp = 1_710_000_000_000 + Int64(index)
packet.privateKey = "hash"
packet.messageId = "empty-decrypted-\(index)-\(UUID().uuidString)"
packet.attachments = []
packet.aesChachaKey = ""
processedMessageIds.append(packet.messageId)
await session.testProcessIncomingMessage(packet)
}
try await Task.sleep(nanoseconds: 900_000_000)
XCTAssertEqual(triggerCount, 1)
XCTAssertEqual(session.malformedMessageResyncTriggerCount, 1)
for messageId in processedMessageIds {
XCTAssertFalse(
MessageRepository.shared.hasMessage(messageId),
"Post-decrypt empty payload must not be persisted as a bubble"
)
}
}
private func makePacketMessageData(metaFieldCount: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketMessage.packetId)
stream.writeString("02from")
stream.writeString("02to")
stream.writeString("ciphertext")
stream.writeString("chacha-key")
stream.writeInt64(1_710_000_000_000)
stream.writeString("hash")
stream.writeString("msg-hardening")
stream.writeInt8(1)
stream.writeString("att-1")
stream.writeString("preview")
stream.writeString("blob")
stream.writeInt8(AttachmentType.image.rawValue)
if metaFieldCount >= 2 {
stream.writeString("tag-1")
stream.writeString("cdn.rosetta.im")
}
if metaFieldCount >= 4 {
stream.writeString("02encoded-for")
stream.writeString("desktop")
}
stream.writeString("aes-key")
return stream.toData()
}
private func makeHandshakeData(stateRaw: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketHandshake.packetId)
stream.writeString("hash")
stream.writeString("02public")
stream.writeInt8(1)
stream.writeInt8(15)
stream.writeString("device-id")
stream.writeString("iPhone")
stream.writeString("iOS")
stream.writeInt8(stateRaw)
return stream.toData()
}
private func makeSyncData(statusRaw: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketSync.packetId)
stream.writeInt8(statusRaw)
stream.writeInt64(1_710_000_000_000)
return stream.toData()
}
private func makeSignalCreateRoomData(roomId: String?) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketSignalPeer.packetId)
stream.writeInt8(SignalType.createRoom.rawValue)
stream.writeString("02src")
stream.writeString("02dst")
if let roomId {
stream.writeString(roomId)
}
return stream.toData()
}
private func makeWebRtcData(
includeIdentity: Bool,
publicKey: String = "",
deviceId: String = ""
) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketWebRTC.packetId)
stream.writeInt8(WebRTCSignalType.offer.rawValue)
stream.writeString("{\"type\":\"offer\",\"sdp\":\"v=0\"}")
if includeIdentity {
stream.writeString(publicKey)
stream.writeString(deviceId)
}
return stream.toData()
}
}

View File

@@ -1,14 +1,11 @@
import Testing
@testable import Rosetta
/// Verifies PacketPushNotification wire format matches server
/// (im.rosetta.packet.Packet16PushNotification).
///
/// Server wire format:
/// Verifies PacketPushNotification wire format matches Server/Android:
/// writeInt16(packetId=0x10)
/// writeString(notificationToken)
/// writeInt8(action) 0=subscribe, 1=unsubscribe
/// writeInt8(tokenType) 0=FCM, 1=VoIPApns
/// writeInt8(action)
/// writeInt8(tokenType)
/// writeString(deviceId)
struct PushNotificationPacketTests {
@@ -24,49 +21,49 @@ struct PushNotificationPacketTests {
#expect(PushNotificationAction.unsubscribe.rawValue == 1)
}
@Test("PushTokenType.fcm == 0 (server: FCM)")
@Test("PushTokenType.fcm == 0")
func fcmTokenTypeValue() {
#expect(PushTokenType.fcm.rawValue == 0)
}
@Test("PushTokenType.voipApns == 1 (server: VoIPApns)")
@Test("PushTokenType.voipApns == 1")
func voipTokenTypeValue() {
#expect(PushTokenType.voipApns.rawValue == 1)
}
// MARK: - Round Trip (encode decode)
@Test("FCM subscribe round-trip preserves all fields")
func fcmSubscribeRoundTrip() throws {
@Test("Subscribe round-trip preserves all fields")
func subscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "test-fcm-token-abc123"
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "device-id-xyz"
packet.deviceId = "ios-device-1"
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "test-fcm-token-abc123")
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "device-id-xyz")
#expect(decoded.deviceId == "ios-device-1")
}
@Test("VoIP unsubscribe round-trip preserves all fields")
func voipUnsubscribeRoundTrip() throws {
@Test("Unsubscribe round-trip preserves all fields")
func unsubscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "voip-hex-token-deadbeef"
packet.notificationsToken = "test-token-deadbeef"
packet.action = .unsubscribe
packet.tokenType = .voipApns
packet.deviceId = "another-device-id"
packet.deviceId = "ios-device-2"
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "voip-hex-token-deadbeef")
#expect(decoded.notificationsToken == "test-token-deadbeef")
#expect(decoded.action == .unsubscribe)
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "another-device-id")
#expect(decoded.deviceId == "ios-device-2")
}
@Test("Empty token and deviceId round-trip")
@Test("Empty token round-trip")
func emptyFieldsRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = ""
@@ -76,6 +73,7 @@ struct PushNotificationPacketTests {
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "")
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "")
}
@@ -100,7 +98,7 @@ struct PushNotificationPacketTests {
var packet = PacketPushNotification()
packet.notificationsToken = "T" // 1 UTF-16 code unit
packet.action = .subscribe // 0
packet.tokenType = .voipApns // 1
packet.tokenType = .fcm // 0
packet.deviceId = "D" // 1 UTF-16 code unit
let data = PacketRegistry.encode(packet)
@@ -110,9 +108,9 @@ struct PushNotificationPacketTests {
// [2-5] string length = 1 (UInt32 big-endian) for "T"
// [6-7] 'T' = 0x0054 (UInt16 big-endian)
// [8] action = 0 (subscribe)
// [9] tokenType = 1 (voipApns)
// [9] tokenType = 0 (fcm)
// [10-13] string length = 1 for "D"
// [14-15] 'D' = 0x0044 (UInt16 big-endian)
// [14-15] 'D' = 0x0044
#expect(data.count == 16)
@@ -133,8 +131,8 @@ struct PushNotificationPacketTests {
// action = 0 (subscribe)
#expect(data[8] == 0x00)
// tokenType = 1 (voipApns)
#expect(data[9] == 0x01)
// tokenType = 0 (fcm)
#expect(data[9] == 0x00)
// deviceId string length = 1
#expect(data[10] == 0x00)

View File

@@ -153,6 +153,65 @@ final class SchemaParityTests: XCTestCase {
XCTAssertEqual(decoded.packetId, 0x06)
XCTAssertEqual(decodedMessage.attachments.first?.type, .call)
XCTAssertFalse(decodedMessage.isMalformed)
}
func testPacketMessageDecodeSupportsAttachmentMeta4Compatibility() throws {
let encoded = makePacketMessageData(attachmentMetaFieldCount: 4)
guard let decoded = PacketRegistry.decode(from: encoded),
let message = decoded.packet as? PacketMessage else {
XCTFail("Failed to decode packet with 4 attachment meta fields")
return
}
XCTAssertEqual(decoded.packetId, 0x06)
XCTAssertFalse(message.isMalformed)
XCTAssertEqual(message.fromPublicKey, "02from")
XCTAssertEqual(message.toPublicKey, "02to")
XCTAssertEqual(message.messageId, "msg-compat")
XCTAssertEqual(message.aesChachaKey, "aes-key")
XCTAssertEqual(message.attachments.count, 1)
XCTAssertEqual(message.attachments[0].transportTag, "tag-1")
XCTAssertEqual(message.attachments[0].transportServer, "cdn.rosetta.im")
}
func testPacketMessageDecodeSupportsAttachmentMeta0Compatibility() throws {
let encoded = makePacketMessageData(attachmentMetaFieldCount: 0)
guard let decoded = PacketRegistry.decode(from: encoded),
let message = decoded.packet as? PacketMessage else {
XCTFail("Failed to decode packet with 0 attachment meta fields")
return
}
XCTAssertEqual(decoded.packetId, 0x06)
XCTAssertFalse(message.isMalformed)
XCTAssertEqual(message.messageId, "msg-compat")
XCTAssertEqual(message.aesChachaKey, "aes-key")
XCTAssertEqual(message.attachments.count, 1)
XCTAssertEqual(message.attachments[0].transportTag, "")
XCTAssertEqual(message.attachments[0].transportServer, "")
}
func testPacketMessageDecodeMarksMalformedForTruncatedOrMisalignedPayload() throws {
let canonical = makePacketMessageData(attachmentMetaFieldCount: 2)
let truncated = canonical.dropLast(3)
let withTrailingByte = canonical + Data([0x00])
guard let decodedTruncated = PacketRegistry.decode(from: Data(truncated)),
let messageTruncated = decodedTruncated.packet as? PacketMessage else {
XCTFail("Failed to decode truncated packet wrapper")
return
}
XCTAssertTrue(messageTruncated.isMalformed)
XCTAssertFalse(messageTruncated.malformedFingerprint.isEmpty)
guard let decodedTrailing = PacketRegistry.decode(from: withTrailingByte),
let messageTrailing = decodedTrailing.packet as? PacketMessage else {
XCTFail("Failed to decode trailing-byte packet wrapper")
return
}
XCTAssertTrue(messageTrailing.isMalformed)
XCTAssertFalse(messageTrailing.malformedFingerprint.isEmpty)
}
func testSessionPacketContextResolverAcceptsGroupWireShape() throws {
@@ -188,4 +247,33 @@ final class SchemaParityTests: XCTestCase {
_ = stream
XCTAssertTrue(true)
}
private func makePacketMessageData(attachmentMetaFieldCount: Int) -> Data {
let stream = Rosetta.Stream()
stream.writeInt16(PacketMessage.packetId)
stream.writeString("02from")
stream.writeString("02to")
stream.writeString("ciphertext")
stream.writeString("chacha-key")
stream.writeInt64(1_710_000_000_000)
stream.writeString("hash")
stream.writeString("msg-compat")
stream.writeInt8(1)
stream.writeString("att-1")
stream.writeString("preview")
stream.writeString("blob")
stream.writeInt8(AttachmentType.image.rawValue)
if attachmentMetaFieldCount >= 2 {
stream.writeString("tag-1")
stream.writeString("cdn.rosetta.im")
}
if attachmentMetaFieldCount >= 4 {
stream.writeString("02encoded-for")
stream.writeString("desktop")
}
stream.writeString("aes-key")
return stream.toData()
}
}