mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:05:19 +00:00
Convert the remaining tests to Swift Testing (#781)
* SwiftTesting: NoiseProtocolTests + BinaryProtocolPaddingTests * SwiftTesting: `NotificationStreamAssemblerTests` * SwiftTesting: `NostrProtocolTests` * SwiftTesting: `BinaryProtocolTests` * SwiftTesting: `PeerIDTests` * SwiftTesting: `BLEServiceTests` * SwiftTesting: `CommandProcessorTests` * SwiftTesting: `GCSFilterTests` * SwiftTesting: `GeohashBookmarksStoreTests` * Remove `peerID` test constants * Remove PeerID + String interop from tests * Refactor IntegrationTests to extract state management * Refactor global state management of MockBLEService * NoiseProtocolSwiftTests: `actor` -> `struct` * Remove measurement tests w/ no benchmark * `NoiseProtocolSwiftTests` -> `NoiseProtocolTests` * SwiftTesting: `LocationChannelsTests` * SwiftTesting: `GossipSyncManagerTests` * SwiftTesting: `LocationNotesManagerTests` * Global `sleep` function for tests * SwiftTesting: `IntegrationTests` --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -6,52 +6,31 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
final class IntegrationTests: XCTestCase {
|
||||
struct IntegrationTests {
|
||||
|
||||
var nodes: [String: MockBLEService] = [:]
|
||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||
private var mockKeychain: MockKeychain!
|
||||
private var helper = TestNetworkHelper()
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Use the in-memory test bus with autoFlood enabled to simulate
|
||||
// broadcast propagation across a larger mesh. Integration-only.
|
||||
MockBLEService.resetTestBus()
|
||||
MockBLEService.autoFloodEnabled = true
|
||||
mockKeychain = MockKeychain()
|
||||
|
||||
// Create a network of nodes
|
||||
createNode("Alice", peerID: TestConstants.testPeerID1)
|
||||
createNode("Bob", peerID: TestConstants.testPeerID2)
|
||||
createNode("Charlie", peerID: TestConstants.testPeerID3)
|
||||
createNode("David", peerID: TestConstants.testPeerID4)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Disable flooding to avoid cross-test interference
|
||||
MockBLEService.autoFloodEnabled = false
|
||||
nodes.removeAll()
|
||||
noiseManagers.removeAll()
|
||||
mockKeychain = nil
|
||||
super.tearDown()
|
||||
init() {
|
||||
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
|
||||
}
|
||||
|
||||
// MARK: - Multi-Peer Scenarios
|
||||
|
||||
func testFullMeshCommunication() {
|
||||
// Create full mesh - everyone connected to everyone
|
||||
connectFullMesh()
|
||||
@Test func fullMeshCommunication() async throws {
|
||||
helper.connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "All nodes communicate")
|
||||
var messageMatrix: [String: Set<String>] = [:]
|
||||
for (senderName, _) in helper.nodes { messageMatrix[senderName] = [] }
|
||||
|
||||
// Track all receivers; parse sender name from message content "Hello from <Name>"
|
||||
for (senderName, _) in nodes { messageMatrix[senderName] = [] }
|
||||
for (receiverName, receiver) in nodes {
|
||||
for (receiverName, receiver) in helper.nodes {
|
||||
receiver.messageDeliveryHandler = { message in
|
||||
let parts = message.content.components(separatedBy: " ")
|
||||
if let last = parts.last, message.content.contains("Hello from") {
|
||||
@@ -62,370 +41,336 @@ final class IntegrationTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// Each node sends a message
|
||||
for (name, node) in nodes {
|
||||
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
|
||||
for (name, node) in helper.nodes {
|
||||
node.sendMessage("Hello from \(name)")
|
||||
}
|
||||
|
||||
// Wait and verify
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
// Each sender should have reached all other nodes
|
||||
for (sender, receivers) in messageMatrix {
|
||||
let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender })
|
||||
XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes")
|
||||
}
|
||||
expectation.fulfill()
|
||||
// Each sender should have reached all other nodes
|
||||
for (sender, receivers) in messageMatrix {
|
||||
let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
|
||||
#expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testDynamicTopologyChanges() {
|
||||
@Test func dynamicTopologyChanges() async throws {
|
||||
// Start with Alice -> Bob -> Charlie
|
||||
connect("Alice", "Bob")
|
||||
connect("Bob", "Charlie")
|
||||
helper.connect("Alice", "Bob")
|
||||
helper.connect("Bob", "Charlie")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Topology changes handled")
|
||||
var phase = 1
|
||||
|
||||
// Phase 1: Test initial topology
|
||||
nodes["Charlie"]!.messageDeliveryHandler = { message in
|
||||
if phase == 1 && message.sender == "Alice" {
|
||||
// Now change topology: disconnect Bob, connect Alice-Charlie
|
||||
self.disconnect("Alice", "Bob")
|
||||
self.disconnect("Bob", "Charlie")
|
||||
self.connect("Alice", "Charlie")
|
||||
phase = 2
|
||||
|
||||
// Send another message
|
||||
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil)
|
||||
} else if phase == 2 && message.content == "Direct message" {
|
||||
expectation.fulfill()
|
||||
try await confirmation("Topology changes handled") { receiveMessage in
|
||||
var phase = 1
|
||||
|
||||
helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
|
||||
if phase == 1 && message.sender == "Alice" {
|
||||
// Now change topology: disconnect Bob, connect Alice-Charlie
|
||||
helper.disconnect("Alice", "Bob")
|
||||
helper.disconnect("Bob", "Charlie")
|
||||
helper.connect("Alice", "Charlie")
|
||||
phase = 2
|
||||
|
||||
// Send another message
|
||||
helper.nodes["Alice"]!.sendMessage("Direct message")
|
||||
} else if phase == 2 && message.content == "Direct message" {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
|
||||
// Allow relay handler to be set before first send
|
||||
try await sleep(0.05)
|
||||
helper.nodes["Alice"]!.sendMessage("Relayed message")
|
||||
}
|
||||
|
||||
// Initial message through relay
|
||||
// Allow relay handler to be set before first send
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
self.nodes["Alice"]!.sendMessage("Relayed message", mentions: [], to: nil)
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testNetworkPartitionRecovery() {
|
||||
@Test func networkPartitionRecovery() async throws {
|
||||
// Create two partitions
|
||||
connect("Alice", "Bob")
|
||||
connect("Charlie", "David")
|
||||
helper.connect("Alice", "Bob")
|
||||
helper.connect("Charlie", "David")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Partitions merge and communicate")
|
||||
let messagesBeforeMerge = 0
|
||||
var messagesAfterMerge = 0
|
||||
|
||||
// Monitor cross-partition messages
|
||||
nodes["David"]!.messageDeliveryHandler = { message in
|
||||
if message.sender == "Alice" {
|
||||
messagesAfterMerge += 1
|
||||
if messagesAfterMerge == 1 {
|
||||
expectation.fulfill()
|
||||
try await confirmation("Partitions merge and communicate") { receiveMessage in
|
||||
// Monitor cross-partition messages
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||
if message.sender == "Alice" {
|
||||
messagesAfterMerge += 1
|
||||
if messagesAfterMerge == 1 {
|
||||
receiveMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to send across partition (should fail)
|
||||
nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil)
|
||||
|
||||
// Merge partitions after delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
|
||||
// Try to send across partition (should fail)
|
||||
helper.nodes["Alice"]!.sendMessage("Before merge")
|
||||
|
||||
// Merge partitions after delay
|
||||
try await sleep(0.05)
|
||||
// Connect partitions
|
||||
self.connect("Bob", "Charlie")
|
||||
helper.connect("Bob", "Charlie")
|
||||
|
||||
// Enable relay
|
||||
self.setupRelay("Bob", nextHops: ["Charlie"])
|
||||
self.setupRelay("Charlie", nextHops: ["David"])
|
||||
helper.setupRelay("Bob", nextHops: ["Charlie"])
|
||||
helper.setupRelay("Charlie", nextHops: ["David"])
|
||||
|
||||
// Send message across merged network
|
||||
self.nodes["Alice"]!.sendMessage("After merge", mentions: [], to: nil)
|
||||
helper.nodes["Alice"]!.sendMessage("After merge")
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertEqual(messagesBeforeMerge, 0)
|
||||
XCTAssertEqual(messagesAfterMerge, 1)
|
||||
#expect(messagesBeforeMerge == 0)
|
||||
#expect(messagesAfterMerge == 1)
|
||||
}
|
||||
|
||||
// MARK: - Mixed Message Type Scenarios
|
||||
|
||||
func testMixedPublicPrivateMessages() throws {
|
||||
connectFullMesh()
|
||||
@Test func mixedPublicPrivateMessages() async throws {
|
||||
helper.connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "Mixed messages handled correctly")
|
||||
var publicCount = 0
|
||||
var privateCount = 0
|
||||
|
||||
// Bob monitors messages
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.recipientNickname == "Bob" {
|
||||
privateCount += 1
|
||||
} else if !message.isPrivate {
|
||||
publicCount += 1
|
||||
await confirmation("Mixed messages handled correctly") { completion in
|
||||
// Bob monitors messages
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.isPrivate && message.recipientNickname == "Bob" {
|
||||
privateCount += 1
|
||||
} else if !message.isPrivate {
|
||||
publicCount += 1
|
||||
}
|
||||
|
||||
if publicCount == 2 && privateCount == 1 {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount == 2 && privateCount == 1 {
|
||||
expectation.fulfill()
|
||||
}
|
||||
// Alice sends mixed messages
|
||||
helper.nodes["Alice"]!.sendMessage("Public 1")
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
helper.nodes["Alice"]!.sendMessage("Public 2")
|
||||
}
|
||||
|
||||
// Alice sends mixed messages
|
||||
nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil)
|
||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
nodes["Alice"]!.sendMessage("Public 2", mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertEqual(publicCount, 2)
|
||||
XCTAssertEqual(privateCount, 1)
|
||||
#expect(publicCount == 2)
|
||||
#expect(privateCount == 1)
|
||||
}
|
||||
|
||||
func testEncryptedAndUnencryptedMix() throws {
|
||||
connect("Alice", "Bob")
|
||||
@Test func encryptedAndUnencryptedMix() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
|
||||
// Setup Noise session
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Both encrypted and plain messages work")
|
||||
var plainCount = 0
|
||||
var encryptedCount = 0
|
||||
|
||||
// Setup handlers
|
||||
// Plain path: send public message and count at Bob
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Plain message" { plainCount += 1 }
|
||||
if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() }
|
||||
}
|
||||
|
||||
// Encrypted path: use NoiseSessionManager explicitly
|
||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||
let ciphertext = try noiseManagers["Alice"]!.encrypt(plaintext, for: TestConstants.testPeerID2)
|
||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? self.noiseManagers["Bob"]!.decrypt(ciphertext, from: TestConstants.testPeerID1),
|
||||
data == plaintext {
|
||||
encryptedCount = 1
|
||||
if plainCount == 1 { expectation.fulfill() }
|
||||
try await confirmation("Both encrypted and plain messages work") { completion in
|
||||
// Plain path: send public message and count at Bob
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Plain message" {
|
||||
plainCount += 1
|
||||
}
|
||||
if plainCount == 1 && encryptedCount == 1 {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypted path: use NoiseSessionManager explicitly
|
||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
|
||||
|
||||
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? helper.noiseManagers["Bob"]!.decrypt(ciphertext, from: helper.nodes["Alice"]!.peerID),
|
||||
data == plaintext {
|
||||
encryptedCount = 1
|
||||
if plainCount == 1 {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
helper.nodes["Alice"]!.sendMessage("Plain message")
|
||||
// Deliver encrypted packet directly
|
||||
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
}
|
||||
|
||||
nodes["Alice"]!.sendMessage("Plain message", mentions: [], to: nil)
|
||||
// Deliver encrypted packet directly
|
||||
let encPacket = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
// MARK: - Network Resilience Tests
|
||||
|
||||
func testMessageDeliveryUnderChurn() {
|
||||
@Test func messageDeliveryUnderChurn() async throws {
|
||||
// Start with stable network
|
||||
connectFullMesh()
|
||||
helper.connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "Messages delivered despite churn")
|
||||
var receivedMessages = Set<String>()
|
||||
let totalMessages = 10
|
||||
|
||||
// David tracks received messages
|
||||
nodes["David"]!.messageDeliveryHandler = { message in
|
||||
receivedMessages.insert(message.content)
|
||||
if receivedMessages.count == totalMessages {
|
||||
expectation.fulfill()
|
||||
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
|
||||
// David tracks received messages
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
// Send messages while churning network
|
||||
for i in 0..<totalMessages {
|
||||
nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||
|
||||
// Simulate churn
|
||||
if i % 3 == 0 {
|
||||
// Disconnect and reconnect random connection
|
||||
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
||||
let randomPair = pairs.randomElement()!
|
||||
disconnect(randomPair.0, randomPair.1)
|
||||
// Send messages while churning network
|
||||
for i in 0..<totalMessages {
|
||||
helper.nodes["Alice"]!.sendMessage("Message \(i)")
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self.connect(randomPair.0, randomPair.1)
|
||||
// Simulate churn
|
||||
if i % 3 == 0 {
|
||||
// Disconnect and reconnect random connection
|
||||
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
|
||||
let randomPair = pairs.randomElement()!
|
||||
helper.disconnect(randomPair.0, randomPair.1)
|
||||
try await sleep(0.01)
|
||||
helper.connect(randomPair.0, randomPair.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func peerPresenceTrackingAndReconnection() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(receivedMessages.count, totalMessages)
|
||||
}
|
||||
|
||||
func testPeerPresenceTrackingAndReconnection() {
|
||||
// Test that after disconnect/reconnect, message delivery resumes
|
||||
connect("Alice", "Bob")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Delivery after reconnection")
|
||||
var delivered = false
|
||||
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After reconnect" && !delivered {
|
||||
delivered = true
|
||||
expectation.fulfill()
|
||||
await confirmation("Delivery after reconnection") { delivered in
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After reconnect" {
|
||||
delivered()
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate disconnect (out of range)
|
||||
helper.disconnect("Alice", "Bob")
|
||||
// Reconnect
|
||||
helper.connect("Alice", "Bob")
|
||||
|
||||
// Send after reconnection
|
||||
helper.nodes["Alice"]!.sendMessage("After reconnect")
|
||||
}
|
||||
|
||||
// Simulate disconnect (out of range)
|
||||
disconnect("Alice", "Bob")
|
||||
// Reconnect
|
||||
connect("Alice", "Bob")
|
||||
|
||||
// Send after reconnection
|
||||
nodes["Alice"]!.sendMessage("After reconnect", mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertTrue(delivered)
|
||||
}
|
||||
|
||||
func testEncryptedMessageAfterPeerRestart() {
|
||||
// Test that encrypted messages work after one peer restarts
|
||||
connect("Alice", "Bob")
|
||||
@Test func encryptedMessageAfterPeerRestart() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
do {
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
} catch {
|
||||
XCTFail("Failed to establish Noise session: \(error)")
|
||||
Issue.record("Failed to establish Noise session: \(error)")
|
||||
}
|
||||
|
||||
// Exchange an encrypted message
|
||||
let firstExpectation = XCTestExpectation(description: "First message received")
|
||||
nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Before restart" && message.isPrivate {
|
||||
firstExpectation.fulfill()
|
||||
await confirmation("First message received") { received in
|
||||
helper.nodes["Bob"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "Before restart" && message.isPrivate {
|
||||
received()
|
||||
}
|
||||
}
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
}
|
||||
|
||||
nodes["Alice"]!.sendPrivateMessage("Before restart", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
wait(for: [firstExpectation], timeout: TestConstants.defaultTimeout)
|
||||
|
||||
// Simulate Bob restart by recreating his Noise manager
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
|
||||
|
||||
// Re-establish Noise handshake explicitly via managers
|
||||
do {
|
||||
let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1)
|
||||
let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)!
|
||||
let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)!
|
||||
_ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3)
|
||||
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
||||
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
||||
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||
} catch {
|
||||
XCTFail("Failed to re-establish Noise session after restart: \(error)")
|
||||
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||
}
|
||||
|
||||
// Now messages should work again
|
||||
let secondExpectation = XCTestExpectation(description: "Message after restart received")
|
||||
nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After restart success" && message.isPrivate {
|
||||
secondExpectation.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate encrypted message using managers
|
||||
do {
|
||||
let plaintext = "After restart success".data(using: .utf8)!
|
||||
let ciphertext = try noiseManagers["Bob"]!.encrypt(plaintext, for: TestConstants.testPeerID1)
|
||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? self.noiseManagers["Alice"]!.decrypt(pkt.payload, from: TestConstants.testPeerID2),
|
||||
String(data: data, encoding: .utf8) == "After restart success" {
|
||||
secondExpectation.fulfill()
|
||||
}
|
||||
// Now messages should work again - simulate encrypted packet
|
||||
await confirmation("Message after restart received") { received in
|
||||
helper.nodes["Alice"]!.messageDeliveryHandler = { message in
|
||||
if message.content == "After restart success" && message.isPrivate {
|
||||
received()
|
||||
}
|
||||
}
|
||||
nodes["Alice"]!.simulateIncomingPacket(packet)
|
||||
} catch {
|
||||
XCTFail("Encryption after restart failed: \(error)")
|
||||
|
||||
do {
|
||||
let plaintext = "After restart success".data(using: .utf8)!
|
||||
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
|
||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||
if pkt.type == MessageType.noiseEncrypted.rawValue {
|
||||
if let data = try? helper.noiseManagers["Alice"]!.decrypt(pkt.payload, from: helper.nodes["Bob"]!.peerID),
|
||||
String(data: data, encoding: .utf8) == "After restart success" {
|
||||
received()
|
||||
}
|
||||
}
|
||||
}
|
||||
helper.nodes["Alice"]!.simulateIncomingPacket(packet)
|
||||
} catch {
|
||||
Issue.record("Encryption after restart failed: \(error)")
|
||||
}
|
||||
}
|
||||
wait(for: [secondExpectation], timeout: TestConstants.defaultTimeout)
|
||||
}
|
||||
|
||||
func testLargeScaleNetwork() {
|
||||
@Test func largeScaleNetwork() async throws {
|
||||
// Create larger network
|
||||
for i in 5...10 {
|
||||
createNode("Node\(i)", peerID: "PEER\(i)")
|
||||
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
|
||||
}
|
||||
|
||||
// Connect in ring topology with cross-connections
|
||||
let allNodes = Array(nodes.keys).sorted()
|
||||
let allNodes = Array(helper.nodes.keys).sorted()
|
||||
for i in 0..<allNodes.count {
|
||||
// Ring connection
|
||||
connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
||||
helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
|
||||
|
||||
// Cross connection
|
||||
if i + 3 < allNodes.count {
|
||||
connect(allNodes[i], allNodes[i + 3])
|
||||
helper.connect(allNodes[i], allNodes[i + 3])
|
||||
}
|
||||
}
|
||||
|
||||
let expectation = XCTestExpectation(description: "Large network handles broadcast")
|
||||
var nodesReached = Set<String>()
|
||||
|
||||
// All nodes except Alice listen
|
||||
for (name, node) in nodes where name != "Alice" {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == "Broadcast test" {
|
||||
nodesReached.insert(name)
|
||||
if nodesReached.count == self.nodes.count - 1 {
|
||||
expectation.fulfill()
|
||||
await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
|
||||
// All nodes except Alice listen
|
||||
for (name, node) in helper.nodes where name != "Alice" {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.content == "Broadcast test" {
|
||||
nodeReaced()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
helper.nodes["Alice"]!.sendMessage("Broadcast test")
|
||||
}
|
||||
|
||||
// Alice broadcasts
|
||||
nodes["Alice"]!.sendMessage("Broadcast test", mentions: [], to: nil)
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertEqual(nodesReached.count, nodes.count - 1)
|
||||
}
|
||||
|
||||
// MARK: - Stress Tests
|
||||
|
||||
func testHighLoadScenario() {
|
||||
connectFullMesh()
|
||||
@Test func highLoadScenario() async throws {
|
||||
helper.connectFullMesh()
|
||||
|
||||
let messagesPerNode = 25
|
||||
let expectedTotal = messagesPerNode * nodes.count * (nodes.count - 1)
|
||||
var receivedTotal = 0
|
||||
let expectation = XCTestExpectation(description: "High load handled")
|
||||
let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
|
||||
|
||||
// Each node tracks messages
|
||||
for (_, node) in nodes {
|
||||
node.messageDeliveryHandler = { _ in
|
||||
receivedTotal += 1
|
||||
if receivedTotal >= (expectedTotal - 2) {
|
||||
expectation.fulfill()
|
||||
await confirmation("High load handled", expectedCount: expectedTotal) { received in
|
||||
// Each node tracks messages
|
||||
for (_, node) in helper.nodes {
|
||||
node.messageDeliveryHandler = { _ in
|
||||
received()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All nodes send many messages simultaneously
|
||||
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in
|
||||
let nodeName = Array(nodes.keys).sorted()[index]
|
||||
for i in 0..<messagesPerNode {
|
||||
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil)
|
||||
|
||||
// All nodes send many messages simultaneously
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for (name, node) in helper.nodes {
|
||||
group.addTask {
|
||||
for i in 0..<messagesPerNode {
|
||||
node.sendMessage("\(name) message \(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
||||
XCTAssertGreaterThanOrEqual(receivedTotal, expectedTotal - 2)
|
||||
}
|
||||
|
||||
func testMixedTrafficPatterns() {
|
||||
connectFullMesh()
|
||||
@Test func mixedTrafficPatterns() async throws {
|
||||
helper.connectFullMesh()
|
||||
|
||||
let expectation = XCTestExpectation(description: "Mixed traffic handled")
|
||||
var metrics = [
|
||||
"public": 0,
|
||||
"private": 0,
|
||||
@@ -434,7 +379,7 @@ final class IntegrationTests: XCTestCase {
|
||||
]
|
||||
|
||||
// Setup complex handlers
|
||||
for (name, node) in nodes {
|
||||
for (name, node) in helper.nodes {
|
||||
node.messageDeliveryHandler = { message in
|
||||
if message.isPrivate {
|
||||
metrics["private"]! += 1
|
||||
@@ -453,222 +398,119 @@ final class IntegrationTests: XCTestCase {
|
||||
}
|
||||
|
||||
// Generate mixed traffic
|
||||
nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil)
|
||||
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil)
|
||||
helper.nodes["Alice"]!.sendMessage("Public broadcast")
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
|
||||
|
||||
// Disconnect to force relay
|
||||
disconnect("Alice", "David")
|
||||
nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil)
|
||||
helper.disconnect("Alice", "David")
|
||||
helper.nodes["Alice"]!.sendMessage("Needs relay to David")
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
XCTAssertGreaterThan(metrics["public"]!, 0)
|
||||
XCTAssertGreaterThan(metrics["private"]!, 0)
|
||||
XCTAssertGreaterThan(metrics["mentions"]!, 0)
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
#expect(metrics["public", default: 0] > 0)
|
||||
#expect(metrics["private", default: 0] > 0)
|
||||
#expect(metrics["mentions", default: 0] > 0)
|
||||
}
|
||||
|
||||
// MARK: - Security Integration Tests
|
||||
// Replacement for the legacy NACK test: verifies that after a
|
||||
// decryption failure, peers can rehandshake via NoiseSessionManager
|
||||
// and resume secure communication.
|
||||
func testRehandshakeAfterDecryptionFailure() throws {
|
||||
@Test func rehandshakeAfterDecryptionFailure() throws {
|
||||
// Alice <-> Bob connected
|
||||
connect("Alice", "Bob")
|
||||
|
||||
helper.connect("Alice", "Bob")
|
||||
|
||||
// Establish initial Noise session
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
|
||||
guard let aliceManager = noiseManagers["Alice"],
|
||||
let bobManager = noiseManagers["Bob"],
|
||||
let alicePeerID = nodes["Alice"]?.peerID,
|
||||
let bobPeerID = nodes["Bob"]?.peerID else {
|
||||
return XCTFail("Missing managers or peer IDs")
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
|
||||
guard let aliceManager = helper.noiseManagers["Alice"],
|
||||
let bobManager = helper.noiseManagers["Bob"],
|
||||
let alicePeerID = helper.nodes["Alice"]?.peerID,
|
||||
let bobPeerID = helper.nodes["Bob"]?.peerID
|
||||
else {
|
||||
Issue.record("Missing managers or peer IDs")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Baseline: encrypt from Alice, decrypt at Bob
|
||||
let plaintext1 = Data("hello-secure".utf8)
|
||||
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
|
||||
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
|
||||
XCTAssertEqual(decrypted1, plaintext1)
|
||||
|
||||
#expect(decrypted1 == plaintext1)
|
||||
|
||||
// Simulate decryption failure by corrupting ciphertext
|
||||
var corrupted = encrypted1
|
||||
if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF }
|
||||
do {
|
||||
let corrupted = encrypted1.prefix(15)
|
||||
#expect(throws: NoiseError.invalidCiphertext) {
|
||||
_ = try bobManager.decrypt(corrupted, from: alicePeerID)
|
||||
XCTFail("Corrupted ciphertext should not decrypt")
|
||||
} catch {
|
||||
// Expected: treat as session desync and rehandshake
|
||||
}
|
||||
|
||||
|
||||
// Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
|
||||
bobManager.removeSession(for: alicePeerID)
|
||||
try establishNoiseSession("Bob", "Alice")
|
||||
|
||||
try helper.establishNoiseSession("Bob", "Alice")
|
||||
|
||||
// After rehandshake, encryption/decryption works again
|
||||
let plaintext2 = Data("hello-again".utf8)
|
||||
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
|
||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
|
||||
XCTAssertEqual(decrypted2, plaintext2)
|
||||
#expect(decrypted2 == plaintext2)
|
||||
}
|
||||
|
||||
|
||||
func testEndToEndSecurityScenario() throws {
|
||||
connect("Alice", "Bob")
|
||||
connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
||||
@Test func endToEndSecurityScenario() async throws {
|
||||
helper.connect("Alice", "Bob")
|
||||
helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
|
||||
|
||||
// Establish secure session between Alice and Bob only
|
||||
try establishNoiseSession("Alice", "Bob")
|
||||
try helper.establishNoiseSession("Alice", "Bob")
|
||||
|
||||
let expectation = XCTestExpectation(description: "Secure communication maintained")
|
||||
var bobDecrypted = false
|
||||
var charlieIntercepted = false
|
||||
|
||||
// Setup encryption at Alice
|
||||
nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate && packet.recipientID != nil {
|
||||
// Encrypt private messages
|
||||
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
|
||||
let encPacket = BitchatPacket(
|
||||
type: 0x02,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
self.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bob can decrypt
|
||||
nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
|
||||
let message = BitchatMessage(decrypted) {
|
||||
bobDecrypted = message.content == "Secret message"
|
||||
expectation.fulfill()
|
||||
}
|
||||
|
||||
// Relay encrypted packet to Charlie
|
||||
self.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Charlie cannot decrypt
|
||||
nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
charlieIntercepted = true
|
||||
// Try to decrypt (should fail)
|
||||
do {
|
||||
_ = try self.noiseManagers["Charlie"]?.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
||||
XCTFail("Charlie should not be able to decrypt")
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send encrypted private message
|
||||
nodes["Alice"]!.sendPrivateMessage("Secret message", to: TestConstants.testPeerID2, recipientNickname: "Bob")
|
||||
|
||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
||||
XCTAssertTrue(bobDecrypted)
|
||||
XCTAssertTrue(charlieIntercepted)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func createNode(_ name: String, peerID: PeerID) {
|
||||
let node = MockBLEService()
|
||||
node.myPeerID = peerID
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
// Create Noise manager
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||
}
|
||||
|
||||
private func connect(_ node1: String, _ node2: String) {
|
||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
||||
n1.simulateConnectedPeer(n2.peerID)
|
||||
n2.simulateConnectedPeer(n1.peerID)
|
||||
}
|
||||
|
||||
private func disconnect(_ node1: String, _ node2: String) {
|
||||
guard let n1 = nodes[node1], let n2 = nodes[node2] else { return }
|
||||
n1.simulateDisconnectedPeer(n2.peerID)
|
||||
n2.simulateDisconnectedPeer(n1.peerID)
|
||||
}
|
||||
|
||||
private func connectFullMesh() {
|
||||
let nodeNames = Array(nodes.keys)
|
||||
for i in 0..<nodeNames.count {
|
||||
for j in i+1..<nodeNames.count {
|
||||
connect(nodeNames[i], nodeNames[j])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupRelay(_ nodeName: String, nextHops: [String]) {
|
||||
guard let node = nodes[nodeName] else { return }
|
||||
|
||||
node.packetDeliveryHandler = { packet in
|
||||
guard packet.ttl > 1 else { return }
|
||||
await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
|
||||
|
||||
if let message = BitchatMessage(packet.payload) {
|
||||
guard message.senderPeerID != node.peerID else { return }
|
||||
|
||||
let relayMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: true,
|
||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID,
|
||||
mentions: message.mentions
|
||||
)
|
||||
|
||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||
let relayPacket = BitchatPacket(
|
||||
type: packet.type,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: relayPayload,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl - 1
|
||||
)
|
||||
|
||||
for hop in nextHops {
|
||||
self.nodes[hop]?.simulateIncomingPacket(relayPacket)
|
||||
// Setup encryption at Alice
|
||||
helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x01,
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate && packet.recipientID != nil {
|
||||
// Encrypt private messages
|
||||
if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
|
||||
let encPacket = BitchatPacket(
|
||||
type: 0x02,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: encrypted,
|
||||
signature: packet.signature,
|
||||
ttl: packet.ttl
|
||||
)
|
||||
helper.nodes["Bob"]!.simulateIncomingPacket(encPacket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bob can decrypt
|
||||
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
receivedPacket()
|
||||
if let decrypted = try? helper.noiseManagers["Bob"]!.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID) {
|
||||
#expect(BitchatMessage(decrypted)?.content == "Secret message")
|
||||
} else {
|
||||
Issue.record("Bob was unable to decrypt the message")
|
||||
}
|
||||
|
||||
// Relay encrypted packet to Charlie
|
||||
helper.nodes["Charlie"]!.simulateIncomingPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
// Charlie cannot decrypt
|
||||
helper.nodes["Charlie"]!.packetDeliveryHandler = { packet in
|
||||
if packet.type == 0x02 {
|
||||
receivedPacket()
|
||||
#expect(throws: NoiseSessionError.sessionNotFound, "Charlie should not be able to decrypt") {
|
||||
_ = try helper.noiseManagers["Charlie"]?.decrypt(packet.payload, from: helper.nodes["Alice"]!.peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send encrypted private message
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Secret message", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
}
|
||||
}
|
||||
|
||||
private func establishNoiseSession(_ node1: String, _ node2: String) throws {
|
||||
guard let manager1 = noiseManagers[node1],
|
||||
let manager2 = noiseManagers[node2],
|
||||
let peer1ID = nodes[node1]?.peerID,
|
||||
let peer2ID = nodes[node2]?.peerID else { return }
|
||||
|
||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user