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

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

@@ -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)
}
}