Merge branch 'main' into avoid-recreating-session

This commit is contained in:
jack
2025-10-15 01:04:51 +02:00
committed by GitHub
24 changed files with 1504 additions and 1707 deletions
+1 -2
View File
@@ -6,10 +6,9 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
enum NoiseSessionError: Error { enum NoiseSessionError: Error, Equatable {
case invalidState case invalidState
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case handshakeFailed(Error)
case alreadyEstablished case alreadyEstablished
} }
@@ -216,15 +216,4 @@ final class GeohashBookmarksStore: ObservableObject {
} }
} }
#endif #endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
} }
+207 -197
View File
@@ -6,265 +6,275 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import CoreBluetooth import CoreBluetooth
@testable import bitchat @testable import bitchat
final class BLEServiceTests: XCTestCase { struct BLEServiceTests {
private let service: MockBLEService
private let myUUID = UUID()
private let bus = MockBLEBus()
var service: MockBLEService! init() {
service = MockBLEService.init(bus: bus)
override func setUp() { service.myPeerID = PeerID(str: myUUID.uuidString)
super.setUp()
service = MockBLEService()
service.myPeerID = "TEST1234"
service.mockNickname = "TestUser" service.mockNickname = "TestUser"
} }
override func tearDown() {
service = nil
super.tearDown()
}
// MARK: - Basic Functionality Tests // MARK: - Basic Functionality Tests
func testServiceInitialization() { @Test func serviceInitialization() {
XCTAssertNotNil(service) #expect(service.myPeerID == PeerID(str: myUUID.uuidString))
XCTAssertEqual(service.myPeerID, "TEST1234") #expect(service.myNickname == "TestUser")
XCTAssertEqual(service.myNickname, "TestUser")
} }
func testPeerConnection() { @Test func peerConnection() {
// Test connecting a peer let somePeerID = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER5678")
XCTAssertTrue(service.isPeerConnected("PEER5678"))
XCTAssertEqual(service.getConnectedPeers().count, 1)
// Test disconnecting a peer service.simulateConnectedPeer(somePeerID)
service.simulateDisconnectedPeer("PEER5678") #expect(service.isPeerConnected(somePeerID))
XCTAssertFalse(service.isPeerConnected("PEER5678")) #expect(service.getConnectedPeers().count == 1)
XCTAssertEqual(service.getConnectedPeers().count, 0)
service.simulateDisconnectedPeer(somePeerID)
#expect(!service.isPeerConnected(somePeerID))
#expect(service.getConnectedPeers().count == 0)
} }
func testMultiplePeerConnections() { @Test func multiplePeerConnections() {
service.simulateConnectedPeer("PEER1") let peerID1 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER2") let peerID2 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER3") let peerID3 = PeerID(str: UUID().uuidString)
XCTAssertEqual(service.getConnectedPeers().count, 3) service.simulateConnectedPeer(peerID1)
XCTAssertTrue(service.isPeerConnected("PEER1")) service.simulateConnectedPeer(peerID2)
XCTAssertTrue(service.isPeerConnected("PEER2")) service.simulateConnectedPeer(peerID3)
XCTAssertTrue(service.isPeerConnected("PEER3"))
service.simulateDisconnectedPeer("PEER2") #expect(service.getConnectedPeers().count == 3)
XCTAssertEqual(service.getConnectedPeers().count, 2) #expect(service.isPeerConnected(peerID1))
XCTAssertFalse(service.isPeerConnected("PEER2")) #expect(service.isPeerConnected(peerID2))
#expect(service.isPeerConnected(peerID3))
service.simulateDisconnectedPeer(peerID2)
#expect(service.getConnectedPeers().count == 2)
#expect(!service.isPeerConnected(peerID2))
} }
// MARK: - Message Sending Tests // MARK: - Message Sending Tests
func testSendPublicMessage() { @Test func sendPublicMessage() async throws {
let expectation = XCTestExpectation(description: "Message sent") try await confirmation { receivedPublicMessage in
let delegate = MockBitchatDelegate { message in
#expect(message.content == "Hello, world!")
#expect(message.sender == "TestUser")
#expect(!message.isPrivate)
receivedPublicMessage()
}
service.delegate = delegate
service.sendMessage("Hello, world!")
let delegate = MockBitchatDelegate { message in // Allow async processing
XCTAssertEqual(message.content, "Hello, world!") try await sleep(0.5)
XCTAssertEqual(message.sender, "TestUser")
XCTAssertFalse(message.isPrivate)
expectation.fulfill()
} }
service.delegate = delegate #expect(service.sentMessages.count == 1)
service.sendMessage("Hello, world!")
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(service.sentMessages.count, 1)
} }
func testSendPrivateMessage() { @Test func sendPrivateMessage() async throws {
let expectation = XCTestExpectation(description: "Private message sent") try await confirmation { receivedPrivateMessage in
let delegate = MockBitchatDelegate { message in
#expect(message.content == "Secret message")
#expect(message.sender == "TestUser")
#expect(message.senderPeerID == PeerID(str: myUUID.uuidString))
#expect(message.isPrivate)
#expect(message.recipientNickname == "Bob")
receivedPrivateMessage()
}
service.delegate = delegate
service.sendPrivateMessage(
"Secret message",
to: PeerID(str: UUID().uuidString),
recipientNickname: "Bob",
messageID: "MSG123"
)
let delegate = MockBitchatDelegate { message in // Allow async processing
XCTAssertEqual(message.content, "Secret message") try await sleep(0.5)
XCTAssertEqual(message.sender, "TestUser")
XCTAssertTrue(message.isPrivate)
XCTAssertEqual(message.recipientNickname, "Bob")
expectation.fulfill()
} }
service.delegate = delegate #expect(service.sentMessages.count == 1)
service.sendPrivateMessage("Secret message", to: "PEER5678", recipientNickname: "Bob", messageID: "MSG123")
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(service.sentMessages.count, 1)
} }
func testSendMessageWithMentions() { @Test func sendMessageWithMentions() async throws {
let expectation = XCTestExpectation(description: "Message with mentions sent") try await confirmation { receivedMessageWithMentions in
let delegate = MockBitchatDelegate { message in
#expect(message.content == "@alice @bob check this out")
#expect(message.mentions == ["alice", "bob"])
receivedMessageWithMentions()
}
service.delegate = delegate
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
let delegate = MockBitchatDelegate { message in // Allow async processing
XCTAssertEqual(message.content, "@alice @bob check this out") try await sleep(0.5)
XCTAssertEqual(message.mentions, ["alice", "bob"])
expectation.fulfill()
} }
service.delegate = delegate
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
wait(for: [expectation], timeout: 1.0)
} }
// MARK: - Message Reception Tests // MARK: - Message Reception Tests
func testSimulateIncomingMessage() { @Test func simulateIncomingMessage() async throws {
let expectation = XCTestExpectation(description: "Message received") try await confirmation { receiveMessage in
let peerID = PeerID(str: UUID().uuidString)
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
XCTAssertEqual(message.content, "Incoming message") #expect(message.content == "Incoming message")
XCTAssertEqual(message.sender, "RemoteUser") #expect(message.sender == "RemoteUser")
expectation.fulfill() #expect(message.senderPeerID == peerID)
receiveMessage()
}
service.delegate = delegate
let incomingMessage = BitchatMessage(
id: "MSG456",
sender: "RemoteUser",
content: "Incoming message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
service.simulateIncomingMessage(incomingMessage)
// Allow async processing
try await sleep(0.5)
} }
service.delegate = delegate
let incomingMessage = BitchatMessage(
id: "MSG456",
sender: "RemoteUser",
content: "Incoming message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "REMOTE123",
mentions: nil
)
service.simulateIncomingMessage(incomingMessage)
wait(for: [expectation], timeout: 1.0)
} }
func testSimulateIncomingPacket() { @Test func simulateIncomingPacket() async throws {
let expectation = XCTestExpectation(description: "Packet processed") try await confirmation { processPacket in
let peerID = PeerID(str: UUID().uuidString)
let delegate = MockBitchatDelegate { message in let delegate = MockBitchatDelegate { message in
XCTAssertEqual(message.content, "Packet message") #expect(message.content == "Packet message")
expectation.fulfill() #expect(message.senderPeerID == peerID)
processPacket()
}
service.delegate = delegate
let message = BitchatMessage(
id: "MSG789",
sender: "PacketSender",
content: "Packet message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
let payload = try #require(message.toBinaryPayload(), "Failed to create binary payload")
let packet = BitchatPacket(
type: 0x01,
senderID: peerID.id.data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
} }
service.delegate = delegate
let message = BitchatMessage(
id: "MSG789",
sender: "PacketSender",
content: "Packet message",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "PACKET123",
mentions: nil
)
guard let payload = message.toBinaryPayload() else {
XCTFail("Failed to create binary payload")
return
}
let packet = BitchatPacket(
type: 0x01,
senderID: "PACKET123".data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
wait(for: [expectation], timeout: 1.0)
} }
// MARK: - Peer Nickname Tests // MARK: - Peer Nickname Tests
func testGetPeerNicknames() { @Test func getPeerNicknames() {
service.simulateConnectedPeer("PEER1") let peerID1 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer("PEER2") let peerID2 = PeerID(str: UUID().uuidString)
service.simulateConnectedPeer(peerID1)
service.simulateConnectedPeer(peerID2)
let nicknames = service.getPeerNicknames() let nicknames = service.getPeerNicknames()
XCTAssertEqual(nicknames.count, 2) #expect(nicknames.count == 2)
XCTAssertEqual(nicknames["PEER1"], "MockPeer_PEER1") #expect(nicknames[peerID1] == "MockPeer_\(peerID1)")
XCTAssertEqual(nicknames["PEER2"], "MockPeer_PEER2") #expect(nicknames[peerID2] == "MockPeer_\(peerID2)")
} }
// MARK: - Service State Tests // MARK: - Service State Tests
func testStartStopServices() { @Test func startStopServices() {
// These are mock implementations, just ensure they don't crash
service.startServices() service.startServices()
service.stopServices() service.stopServices()
let somePeerID = PeerID(str: UUID().uuidString)
// Service should still be functional after start/stop service.simulateConnectedPeer(somePeerID)
service.simulateConnectedPeer("PEER999") #expect(service.isPeerConnected(somePeerID))
XCTAssertTrue(service.isPeerConnected("PEER999"))
} }
// MARK: - Message Delivery Handler Tests // MARK: - Message Delivery Handler Tests
func testMessageDeliveryHandler() { @Test func messageDeliveryHandler() async throws {
let expectation = XCTestExpectation(description: "Delivery handler called") try await confirmation { deliveryHandler in
service.packetDeliveryHandler = { packet in
service.packetDeliveryHandler = { packet in if let msg = BitchatMessage(packet.payload) {
if let msg = BitchatMessage(packet.payload) { #expect(msg.content == "Test delivery")
XCTAssertEqual(msg.content, "Test delivery") deliveryHandler()
expectation.fulfill() }
} }
service.sendMessage("Test delivery")
// Allow async processing
try await sleep(0.5)
} }
service.sendMessage("Test delivery")
wait(for: [expectation], timeout: 1.0)
} }
func testPacketDeliveryHandler() { @Test func packetDeliveryHandler() async throws {
let expectation = XCTestExpectation(description: "Packet handler called") try await confirmation("Packet handler called") { packetHandler in
let peerID = PeerID(str: UUID().uuidString)
service.packetDeliveryHandler = { packet in service.packetDeliveryHandler = { packet in
XCTAssertEqual(packet.type, 0x01) #expect(packet.type == 0x01)
expectation.fulfill() #expect(packet.senderID == Data(peerID.id.utf8))
packetHandler()
}
let message = BitchatMessage(
id: "PKT123",
sender: "TestSender",
content: "Test packet",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
let payload = try #require(message.toBinaryPayload(), "Failed to create payload")
let packet = BitchatPacket(
type: 0x01,
senderID: peerID.id.data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
} }
let message = BitchatMessage(
id: "PKT123",
sender: "TestSender",
content: "Test packet",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "TEST123",
mentions: nil
)
guard let payload = message.toBinaryPayload() else {
XCTFail("Failed to create payload")
return
}
let packet = BitchatPacket(
type: 0x01,
senderID: "TEST123".data(using: .utf8)!,
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 3
)
service.simulateIncomingPacket(packet)
wait(for: [expectation], timeout: 1.0)
} }
} }
+12 -24
View File
@@ -1,54 +1,42 @@
import XCTest import Testing
@testable import bitchat @testable import bitchat
final class CommandProcessorTests: XCTestCase { struct CommandProcessorTests {
private var identityManager = MockIdentityManager(MockKeychain())
var identityManager: MockIdentityManager!
override func setUp() {
super.setUp()
// Provide a minimal identity manager for commands that query identity/block lists
identityManager = MockIdentityManager(MockKeychain())
}
override func tearDown() {
identityManager = nil
super.tearDown()
}
@MainActor @MainActor
func test_slap_notFoundGrammar() { @Test func slapNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system") let result = processor.process("/slap @system")
switch result { switch result {
case .error(let message): case .error(let message):
XCTAssertEqual(message, "cannot slap system: not found") #expect(message == "cannot slap system: not found")
default: default:
XCTFail("Expected error result") Issue.record("Expected error result")
} }
} }
@MainActor @MainActor
func test_hug_notFoundGrammar() { @Test func hugNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system") let result = processor.process("/hug @system")
switch result { switch result {
case .error(let message): case .error(let message):
XCTAssertEqual(message, "cannot hug system: not found") #expect(message == "cannot hug system: not found")
default: default:
XCTFail("Expected error result") Issue.record("Expected error result")
} }
} }
@MainActor @MainActor
func test_slap_usageMessage() { @Test func slapUsageMessage() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap") let result = processor.process("/slap")
switch result { switch result {
case .error(let message): case .error(let message):
XCTAssertEqual(message, "usage: /slap <nickname>") #expect(message == "usage: /slap <nickname>")
default: default:
XCTFail("Expected error result for usage message") Issue.record("Expected error result for usage message")
} }
} }
} }
@@ -11,21 +11,19 @@ import CryptoKit
import struct Foundation.UUID import struct Foundation.UUID
@testable import bitchat @testable import bitchat
// TODO: Remove once MockBLEService is refactored to fix race condition
@Suite(.serialized)
struct PrivateChatE2ETests { struct PrivateChatE2ETests {
private let alice: MockBLEService private let alice: MockBLEService
private let bob: MockBLEService private let bob: MockBLEService
private let charlie: MockBLEService private let charlie: MockBLEService
private let mockKeychain: MockKeychain private let mockKeychain = MockKeychain()
private let bus = MockBLEBus()
init() { init() {
// Create services with unique peer IDs to avoid any collision // Create services with unique peer IDs to avoid any collision
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1) alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
mockKeychain = MockKeychain()
} }
// MARK: - Basic Private Messaging Tests // MARK: - Basic Private Messaging Tests
@@ -53,7 +51,7 @@ struct PrivateChatE2ETests {
) )
// Wait a bit to ensure message would have been delivered if it was going to be // Wait a bit to ensure message would have been delivered if it was going to be
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000)) try? await sleep(0.1)
} }
#expect(!bobReceivedMessage, "Bob should not have received the message") #expect(!bobReceivedMessage, "Bob should not have received the message")
@@ -171,7 +169,7 @@ struct PrivateChatE2ETests {
// Send encrypted private message // Send encrypted private message
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testMessage1, TestConstants.testMessage1,
to: TestConstants.testPeerID2, to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -235,7 +233,7 @@ struct PrivateChatE2ETests {
for i in 0..<messageCount { for i in 0..<messageCount {
alice.sendPrivateMessage( alice.sendPrivateMessage(
"Private message \(i)", "Private message \(i)",
to: TestConstants.testPeerID2, to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -254,7 +252,7 @@ struct PrivateChatE2ETests {
alice.sendPrivateMessage( alice.sendPrivateMessage(
TestConstants.testLongMessage, TestConstants.testLongMessage,
to: TestConstants.testPeerID2, to: bob.peerID,
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
} }
@@ -10,22 +10,22 @@ import Testing
import struct Foundation.UUID import struct Foundation.UUID
@testable import bitchat @testable import bitchat
@Suite(.serialized)
struct PublicChatE2ETests { struct PublicChatE2ETests {
private let alice: MockBLEService private let alice: MockBLEService
private let bob: MockBLEService private let bob: MockBLEService
private let charlie: MockBLEService private let charlie: MockBLEService
private let david: MockBLEService private let david: MockBLEService
private let bus = MockBLEBus()
private var receivedMessages: [String: [BitchatMessage]] = [:] private var receivedMessages: [String: [BitchatMessage]] = [:]
init() { init() {
// Create mock services with unique peer IDs to avoid any collision // Create mock services with unique peer IDs to avoid any collision
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1) alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1, bus: bus)
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2) bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2, bus: bus)
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3) charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3, bus: bus)
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4) david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4, bus: bus)
} }
// MARK: - Basic Broadcasting Tests // MARK: - Basic Broadcasting Tests
@@ -34,7 +34,7 @@ struct FragmentationTests {
ble.delegate = capture ble.delegate = capture
// Construct a big packet (3KB) from a remote sender (not our own ID) // Construct a big packet (3KB) from a remote sender (not our own ID)
let remoteShortID: PeerID = "1122334455667788" let remoteShortID = PeerID(str: "1122334455667788")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000) let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
// Use a small fragment size to ensure multiple pieces // Use a small fragment size to ensure multiple pieces
@@ -45,15 +45,15 @@ struct FragmentationTests {
// Inject fragments spaced out to avoid concurrent mutation inside BLEService // Inject fragments spaced out to avoid concurrent mutation inside BLEService
for (i, fragment) in shuffled.enumerated() { for (i, fragment) in shuffled.enumerated() {
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds let delay = 5 * Double(i) * 0.001
Task { Task {
try await Task.sleep(nanoseconds: delay) try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s try await sleep(0.5)
#expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 3_000) #expect(capture.publicMessages.first?.content.count == 3_000)
@@ -69,7 +69,7 @@ struct FragmentationTests {
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
let remoteShortID: PeerID = "A1B2C3D4E5F60708" let remoteShortID = PeerID(str: "A1B2C3D4E5F60708")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048) let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
var frags = fragmentPacket(original, fragmentSize: 300) var frags = fragmentPacket(original, fragmentSize: 300)
@@ -79,15 +79,15 @@ struct FragmentationTests {
} }
for (i, fragment) in frags.enumerated() { for (i, fragment) in frags.enumerated() {
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds let delay = 5 * Double(i) * 0.001
Task { Task {
try await Task.sleep(nanoseconds: delay) try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s try await sleep(0.5)
#expect(capture.publicMessages.count == 1) #expect(capture.publicMessages.count == 1)
#expect(capture.publicMessages.first?.content.count == 2048) #expect(capture.publicMessages.first?.content.count == 2048)
@@ -103,7 +103,7 @@ struct FragmentationTests {
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
let remoteShortID: PeerID = "0011223344556677" let remoteShortID = PeerID(str: "0011223344556677")
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000) let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
let fragments = fragmentPacket(original, fragmentSize: 250) let fragments = fragmentPacket(original, fragmentSize: 250)
@@ -124,15 +124,15 @@ struct FragmentationTests {
} }
for (i, fragment) in corrupted.enumerated() { for (i, fragment) in corrupted.enumerated() {
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds let delay = 5 * Double(i) * 0.001
Task { Task {
try await Task.sleep(nanoseconds: delay) try await sleep(delay)
ble._test_handlePacket(fragment, fromPeerID: remoteShortID) ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
} }
} }
// Allow async processing // Allow async processing
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s try await sleep(0.5)
// Should not deliver since one fragment is invalid and reassembly can't complete // Should not deliver since one fragment is invalid and reassembly can't complete
#expect(capture.publicMessages.isEmpty) #expect(capture.publicMessages.isEmpty)
+9 -8
View File
@@ -1,22 +1,23 @@
import XCTest import Testing
import struct Foundation.Data
@testable import bitchat @testable import bitchat
final class GCSFilterTests: XCTestCase { struct GCSFilterTests {
func testBuildFilterWithDuplicateIdsProducesStableEncoding() { @Test func buildFilterWithDuplicateIdsProducesStableEncoding() {
let id = Data(repeating: 0xAB, count: 16) let id = Data(repeating: 0xAB, count: 16)
let ids = Array(repeating: id, count: 64) let ids = Array(repeating: id, count: 64)
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01) let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
XCTAssertGreaterThanOrEqual(params.m, 1) #expect(params.m >= 1)
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data) let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
XCTAssertLessThanOrEqual(decoded.count, 1) #expect(decoded.count <= 1)
} }
func testBucketAvoidsZeroCandidate() { @Test func bucketAvoidsZeroCandidate() {
let id = Data(repeating: 0x01, count: 16) let id = Data(repeating: 0x01, count: 16)
let bucket = GCSFilter.bucket(for: id, modulus: 2) let bucket = GCSFilter.bucket(for: id, modulus: 2)
XCTAssertNotEqual(bucket, 0) #expect(bucket != 0)
XCTAssertLessThan(bucket, 2) #expect(bucket < 2)
} }
} }
+18 -32
View File
@@ -1,52 +1,38 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class GeohashBookmarksStoreTests: XCTestCase { struct GeohashBookmarksStoreTests {
let storeKey = "locationChannel.bookmarks" private let storeKey = "locationChannel.bookmarks"
var storage: UserDefaults! private let storage = UserDefaults(suiteName: UUID().uuidString)!
var store: GeohashBookmarksStore! private let store: GeohashBookmarksStore
override func setUp() { init() {
super.setUp() store = GeohashBookmarksStore(storage: storage)
// Unique instance for each test to avoid race condition
storage = UserDefaults(suiteName: UUID().uuidString)
store = GeohashBookmarksStore(storage: storage!)
} }
override func tearDown() { @Test func toggleAndNormalize() {
storage.removeObject(forKey: storeKey)
store._resetForTesting()
store = nil
storage = nil
super.tearDown()
}
func testToggleAndNormalize() {
// Start clean // Start clean
XCTAssertTrue(store.bookmarks.isEmpty) #expect(store.bookmarks.isEmpty)
// Add with mixed case and hash prefix // Add with mixed case and hash prefix
store.toggle("#U4PRUY") store.toggle("#U4PRUY")
XCTAssertTrue(store.isBookmarked("u4pruy")) #expect(store.isBookmarked("u4pruy"))
XCTAssertEqual(store.bookmarks.first, "u4pruy") #expect(store.bookmarks.first == "u4pruy")
// Toggling again removes // Toggling again removes
store.toggle("u4pruy") store.toggle("u4pruy")
XCTAssertFalse(store.isBookmarked("u4pruy")) #expect(!store.isBookmarked("u4pruy"))
XCTAssertTrue(store.bookmarks.isEmpty) #expect(store.bookmarks.isEmpty)
} }
func testPersistenceWritten() throws { @Test func persistenceWritten() throws {
store.toggle("ezs42") store.toggle("ezs42")
store.toggle("u4pruy") store.toggle("u4pruy")
// Verify persisted JSON contains both (order not enforced here) // Verify persisted JSON contains both (order not enforced here)
guard let data = storage.data(forKey: storeKey) else { let data = try #require(storage.data(forKey: storeKey), "No persisted data found")
XCTFail("No persisted data found")
return
}
let arr = try JSONDecoder().decode([String].self, from: data) let arr = try JSONDecoder().decode([String].self, from: data)
XCTAssertTrue(arr.contains("ezs42")) #expect(arr.contains("ezs42"))
XCTAssertTrue(arr.contains("u4pruy")) #expect(arr.contains("u4pruy"))
} }
} }
+35 -40
View File
@@ -1,24 +1,28 @@
import Foundation import Foundation
import XCTest import Testing
@testable import bitchat @testable import bitchat
final class GossipSyncManagerTests: XCTestCase { struct GossipSyncManagerTests {
func testConcurrentPacketIntakeAndSyncRequest() {
let manager = GossipSyncManager(myPeerID: "0102030405060708") private let myPeerID = PeerID(str: "0102030405060708")
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
let manager = GossipSyncManager(myPeerID: myPeerID)
let delegate = RecordingDelegate() let delegate = RecordingDelegate()
let sendExpectation = expectation(description: "sync request sent")
delegate.onSend = { sendExpectation.fulfill() }
manager.delegate = delegate manager.delegate = delegate
let iterations = 200 try await confirmation("sync request sent") { sent in
let group = DispatchGroup() delegate.onSend = {
sent()
}
for i in 0..<iterations { let iterations = 200
group.enter() let senderID = try #require(Data(hexString: "1122334455667788"))
DispatchQueue.global(qos: .userInitiated).async {
for i in 0..<iterations {
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.message.rawValue, type: MessageType.message.rawValue,
senderID: Data(hexString: "1122334455667788") ?? Data(), senderID: senderID,
recipientID: nil, recipientID: nil,
timestamp: 1_000_000 + UInt64(i), timestamp: 1_000_000 + UInt64(i),
payload: Data([UInt8(truncatingIfNeeded: i)]), payload: Data([UInt8(truncatingIfNeeded: i)]),
@@ -26,35 +30,26 @@ final class GossipSyncManagerTests: XCTestCase {
ttl: 1 ttl: 1
) )
manager.onPublicPacketSeen(packet) manager.onPublicPacketSeen(packet)
Thread.sleep(forTimeInterval: 0.001) try await sleep(0.001)
group.leave()
} }
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
try await sleep(0.002)
} }
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) { let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0) #expect(lastPacket.type == MessageType.requestSync.rawValue)
} #expect(RequestSyncPacket.decode(from: lastPacket.payload) != nil)
group.wait()
wait(for: [sendExpectation], timeout: 2.0)
guard let lastPacket = delegate.lastPacket else {
XCTFail("Expected sync packet to be sent")
return
}
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
} }
func testStaleAnnouncementsArePurgedWithMessages() { @Test func staleAnnouncementsArePurgedWithMessages() throws {
var config = GossipSyncManager.Config() var config = GossipSyncManager.Config()
config.stalePeerCleanupIntervalSeconds = 0 config.stalePeerCleanupIntervalSeconds = 0
config.stalePeerTimeoutSeconds = 5 config.stalePeerTimeoutSeconds = 5
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config) let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let peerHex = "0011223344556677" let peerHex = "0011223344556677"
let senderData = Data(hexString: peerHex) ?? Data() let senderData = try #require(Data(hexString: peerHex))
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000) let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
let announcePacket = BitchatPacket( let announcePacket = BitchatPacket(
@@ -82,24 +77,24 @@ final class GossipSyncManagerTests: XCTestCase {
// Flush queue without triggering stale cleanup yet // Flush queue without triggering stale cleanup yet
manager._performMaintenanceSynchronously(now: Date()) manager._performMaintenanceSynchronously(now: Date())
XCTAssertTrue(manager._hasAnnouncement(for: PeerID(str: peerHex))) #expect(manager._hasAnnouncement(for: PeerID(str: peerHex)))
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 1) #expect(manager._messageCount(for: PeerID(str: peerHex)) == 1)
// Run cleanup past the timeout // Run cleanup past the timeout
let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1) let future = Date().addingTimeInterval(config.stalePeerTimeoutSeconds + 1)
manager._performMaintenanceSynchronously(now: future) manager._performMaintenanceSynchronously(now: future)
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex))) #expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0) #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
} }
func testIgnoresAnnounceOlderThanStaleTimeout() { @Test func ignoresAnnounceOlderThanStaleTimeout() throws {
var config = GossipSyncManager.Config() var config = GossipSyncManager.Config()
config.stalePeerTimeoutSeconds = 5 config.stalePeerTimeoutSeconds = 5
config.maxMessageAgeSeconds = 100 config.maxMessageAgeSeconds = 100
let manager = GossipSyncManager(myPeerID: "0102030405060708", config: config) let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let peerHex = "8899aabbccddeeff" let peerHex = "8899aabbccddeeff"
let senderData = Data(hexString: peerHex) ?? Data() let senderData = try #require(Data(hexString: peerHex))
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000) let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
let freshMessage = BitchatPacket( let freshMessage = BitchatPacket(
@@ -127,8 +122,8 @@ final class GossipSyncManagerTests: XCTestCase {
manager._performMaintenanceSynchronously() manager._performMaintenanceSynchronously()
XCTAssertFalse(manager._hasAnnouncement(for: PeerID(str: peerHex))) #expect(manager._hasAnnouncement(for: PeerID(str: peerHex)) == false)
XCTAssertEqual(manager._messageCount(for: PeerID(str: peerHex)), 0) #expect(manager._messageCount(for: PeerID(str: peerHex)) == 0)
} }
} }
+290 -448
View File
@@ -6,52 +6,31 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Foundation
import CryptoKit import CryptoKit
import Testing
@testable import bitchat @testable import bitchat
final class IntegrationTests: XCTestCase { struct IntegrationTests {
var nodes: [String: MockBLEService] = [:] private var helper = TestNetworkHelper()
var noiseManagers: [String: NoiseSessionManager] = [:]
private var mockKeychain: MockKeychain!
override func setUp() { init() {
super.setUp() helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
// Use the in-memory test bus with autoFlood enabled to simulate helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
// broadcast propagation across a larger mesh. Integration-only. helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
MockBLEService.resetTestBus() helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
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()
} }
// MARK: - Multi-Peer Scenarios // MARK: - Multi-Peer Scenarios
func testFullMeshCommunication() { @Test func fullMeshCommunication() async throws {
// Create full mesh - everyone connected to everyone helper.connectFullMesh()
connectFullMesh()
let expectation = XCTestExpectation(description: "All nodes communicate")
var messageMatrix: [String: Set<String>] = [:] 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 (receiverName, receiver) in helper.nodes {
for (senderName, _) in nodes { messageMatrix[senderName] = [] }
for (receiverName, receiver) in nodes {
receiver.messageDeliveryHandler = { message in receiver.messageDeliveryHandler = { message in
let parts = message.content.components(separatedBy: " ") let parts = message.content.components(separatedBy: " ")
if let last = parts.last, message.content.contains("Hello from") { 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 helper.nodes {
for (name, node) in nodes { node.sendMessage("Hello from \(name)")
node.sendMessage("Hello from \(name)", mentions: [], to: nil)
} }
// Wait and verify // Each sender should have reached all other nodes
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { for (sender, receivers) in messageMatrix {
// Each sender should have reached all other nodes let expectedReceivers = Set(helper.nodes.keys.filter { $0 != sender })
for (sender, receivers) in messageMatrix { #expect(receivers == expectedReceivers, "\(sender) didn't reach all nodes")
let expectedReceivers = Set(self.nodes.keys.filter { $0 != sender })
XCTAssertEqual(receivers, expectedReceivers, "\(sender) didn't reach all nodes")
}
expectation.fulfill()
} }
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
} }
func testDynamicTopologyChanges() { @Test func dynamicTopologyChanges() async throws {
// Start with Alice -> Bob -> Charlie // Start with Alice -> Bob -> Charlie
connect("Alice", "Bob") helper.connect("Alice", "Bob")
connect("Bob", "Charlie") helper.connect("Bob", "Charlie")
let expectation = XCTestExpectation(description: "Topology changes handled") try await confirmation("Topology changes handled") { receiveMessage in
var phase = 1 var phase = 1
// Phase 1: Test initial topology helper.nodes["Charlie"]!.messageDeliveryHandler = { message in
nodes["Charlie"]!.messageDeliveryHandler = { message in if phase == 1 && message.sender == "Alice" {
if phase == 1 && message.sender == "Alice" { // Now change topology: disconnect Bob, connect Alice-Charlie
// Now change topology: disconnect Bob, connect Alice-Charlie helper.disconnect("Alice", "Bob")
self.disconnect("Alice", "Bob") helper.disconnect("Bob", "Charlie")
self.disconnect("Bob", "Charlie") helper.connect("Alice", "Charlie")
self.connect("Alice", "Charlie") phase = 2
phase = 2
// Send another message // Send another message
self.nodes["Alice"]!.sendMessage("Direct message", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Direct message")
} else if phase == 2 && message.content == "Direct message" { } else if phase == 2 && message.content == "Direct message" {
expectation.fulfill() receiveMessage()
}
} }
}
// Initial message through relay // Allow relay handler to be set before first send
// Allow relay handler to be set before first send try await sleep(0.05)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { helper.nodes["Alice"]!.sendMessage("Relayed message")
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 // Create two partitions
connect("Alice", "Bob") helper.connect("Alice", "Bob")
connect("Charlie", "David") helper.connect("Charlie", "David")
let expectation = XCTestExpectation(description: "Partitions merge and communicate")
let messagesBeforeMerge = 0 let messagesBeforeMerge = 0
var messagesAfterMerge = 0 var messagesAfterMerge = 0
// Monitor cross-partition messages try await confirmation("Partitions merge and communicate") { receiveMessage in
nodes["David"]!.messageDeliveryHandler = { message in // Monitor cross-partition messages
if message.sender == "Alice" { helper.nodes["David"]!.messageDeliveryHandler = { message in
messagesAfterMerge += 1 if message.sender == "Alice" {
if messagesAfterMerge == 1 { messagesAfterMerge += 1
expectation.fulfill() if messagesAfterMerge == 1 {
receiveMessage()
}
} }
} }
}
// Try to send across partition (should fail) // Try to send across partition (should fail)
nodes["Alice"]!.sendMessage("Before merge", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Before merge")
// Merge partitions after delay // Merge partitions after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { try await sleep(0.05)
// Connect partitions // Connect partitions
self.connect("Bob", "Charlie") helper.connect("Bob", "Charlie")
// Enable relay // Enable relay
self.setupRelay("Bob", nextHops: ["Charlie"]) helper.setupRelay("Bob", nextHops: ["Charlie"])
self.setupRelay("Charlie", nextHops: ["David"]) helper.setupRelay("Charlie", nextHops: ["David"])
// Send message across merged network // 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) #expect(messagesBeforeMerge == 0)
XCTAssertEqual(messagesBeforeMerge, 0) #expect(messagesAfterMerge == 1)
XCTAssertEqual(messagesAfterMerge, 1)
} }
// MARK: - Mixed Message Type Scenarios // MARK: - Mixed Message Type Scenarios
func testMixedPublicPrivateMessages() throws { @Test func mixedPublicPrivateMessages() async throws {
connectFullMesh() helper.connectFullMesh()
let expectation = XCTestExpectation(description: "Mixed messages handled correctly")
var publicCount = 0 var publicCount = 0
var privateCount = 0 var privateCount = 0
// Bob monitors messages await confirmation("Mixed messages handled correctly") { completion in
nodes["Bob"]!.messageDeliveryHandler = { message in // Bob monitors messages
if message.isPrivate && message.recipientNickname == "Bob" { helper.nodes["Bob"]!.messageDeliveryHandler = { message in
privateCount += 1 if message.isPrivate && message.recipientNickname == "Bob" {
} else if !message.isPrivate { privateCount += 1
publicCount += 1 } else if !message.isPrivate {
publicCount += 1
}
if publicCount == 2 && privateCount == 1 {
completion()
}
} }
if publicCount == 2 && privateCount == 1 { // Alice sends mixed messages
expectation.fulfill() 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 #expect(publicCount == 2)
nodes["Alice"]!.sendMessage("Public 1", mentions: [], to: nil) #expect(privateCount == 1)
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)
} }
func testEncryptedAndUnencryptedMix() throws { @Test func encryptedAndUnencryptedMix() async throws {
connect("Alice", "Bob") helper.connect("Alice", "Bob")
// Setup Noise session // 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 plainCount = 0
var encryptedCount = 0 var encryptedCount = 0
// Setup handlers try await confirmation("Both encrypted and plain messages work") { completion in
// Plain path: send public message and count at Bob // Plain path: send public message and count at Bob
nodes["Bob"]!.messageDeliveryHandler = { message in helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Plain message" { plainCount += 1 } if message.content == "Plain message" {
if plainCount == 1 && encryptedCount == 1 { expectation.fulfill() } plainCount += 1
} }
if plainCount == 1 && encryptedCount == 1 {
// Encrypted path: use NoiseSessionManager explicitly completion()
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() }
} }
} }
// 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 // MARK: - Network Resilience Tests
func testMessageDeliveryUnderChurn() { @Test func messageDeliveryUnderChurn() async throws {
// Start with stable network // Start with stable network
connectFullMesh() helper.connectFullMesh()
let expectation = XCTestExpectation(description: "Messages delivered despite churn")
var receivedMessages = Set<String>()
let totalMessages = 10 let totalMessages = 10
// David tracks received messages try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
nodes["David"]!.messageDeliveryHandler = { message in // David tracks received messages
receivedMessages.insert(message.content) helper.nodes["David"]!.messageDeliveryHandler = { message in
if receivedMessages.count == totalMessages { completion()
expectation.fulfill()
} }
}
// Send messages while churning network // Send messages while churning network
for i in 0..<totalMessages { for i in 0..<totalMessages {
nodes["Alice"]!.sendMessage("Message \(i)", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Message \(i)")
// Simulate churn // Simulate churn
if i % 3 == 0 { if i % 3 == 0 {
// Disconnect and reconnect random connection // Disconnect and reconnect random connection
let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")] let pairs = [("Alice", "Bob"), ("Bob", "Charlie"), ("Charlie", "David")]
let randomPair = pairs.randomElement()! let randomPair = pairs.randomElement()!
disconnect(randomPair.0, randomPair.1) helper.disconnect(randomPair.0, randomPair.1)
try await sleep(0.01)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { helper.connect(randomPair.0, randomPair.1)
self.connect(randomPair.0, randomPair.1)
} }
} }
} }
wait(for: [expectation], timeout: TestConstants.longTimeout)
XCTAssertEqual(receivedMessages.count, totalMessages)
} }
func testPeerPresenceTrackingAndReconnection() { @Test func peerPresenceTrackingAndReconnection() async throws {
// Test that after disconnect/reconnect, message delivery resumes helper.connect("Alice", "Bob")
connect("Alice", "Bob")
let expectation = XCTestExpectation(description: "Delivery after reconnection") await confirmation("Delivery after reconnection") { delivered in
var delivered = false helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "After reconnect" {
nodes["Bob"]!.messageDeliveryHandler = { message in delivered()
if message.content == "After reconnect" && !delivered { }
delivered = true
expectation.fulfill()
} }
// 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 func encryptedMessageAfterPeerRestart() async throws {
// Test that encrypted messages work after one peer restarts helper.connect("Alice", "Bob")
connect("Alice", "Bob")
do { do {
try establishNoiseSession("Alice", "Bob") try helper.establishNoiseSession("Alice", "Bob")
} catch { } catch {
XCTFail("Failed to establish Noise session: \(error)") Issue.record("Failed to establish Noise session: \(error)")
} }
// Exchange an encrypted message // Exchange an encrypted message
let firstExpectation = XCTestExpectation(description: "First message received") await confirmation("First message received") { received in
nodes["Bob"]!.messageDeliveryHandler = { message in helper.nodes["Bob"]!.messageDeliveryHandler = { message in
if message.content == "Before restart" && message.isPrivate { if message.content == "Before restart" && message.isPrivate {
firstExpectation.fulfill() 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 // Simulate Bob restart by recreating his Noise manager
let bobKey = Curve25519.KeyAgreement.PrivateKey() 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 // Re-establish Noise handshake explicitly via managers
do { do {
let m1 = try noiseManagers["Bob"]!.initiateHandshake(with: TestConstants.testPeerID1) let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
let m2 = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m1)! let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
let m3 = try noiseManagers["Bob"]!.handleIncomingHandshake(from: TestConstants.testPeerID1, message: m2)! let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
_ = try noiseManagers["Alice"]!.handleIncomingHandshake(from: TestConstants.testPeerID2, message: m3) _ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
} catch { } 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 // Now messages should work again - simulate encrypted packet
let secondExpectation = XCTestExpectation(description: "Message after restart received") await confirmation("Message after restart received") { received in
nodes["Alice"]!.messageDeliveryHandler = { message in helper.nodes["Alice"]!.messageDeliveryHandler = { message in
if message.content == "After restart success" && message.isPrivate { if message.content == "After restart success" && message.isPrivate {
secondExpectation.fulfill() received()
}
}
// 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()
}
} }
} }
nodes["Alice"]!.simulateIncomingPacket(packet)
} catch { do {
XCTFail("Encryption after restart failed: \(error)") 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 // Create larger network
for i in 5...10 { 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 // 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 { for i in 0..<allNodes.count {
// Ring connection // Ring connection
connect(allNodes[i], allNodes[(i + 1) % allNodes.count]) helper.connect(allNodes[i], allNodes[(i + 1) % allNodes.count])
// Cross connection // Cross connection
if i + 3 < allNodes.count { 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") await confirmation("Large network handles broadcast", expectedCount: helper.nodes.count - 1) { nodeReaced in
var nodesReached = Set<String>() // All nodes except Alice listen
for (name, node) in helper.nodes where name != "Alice" {
// All nodes except Alice listen node.messageDeliveryHandler = { message in
for (name, node) in nodes where name != "Alice" { if message.content == "Broadcast test" {
node.messageDeliveryHandler = { message in nodeReaced()
if message.content == "Broadcast test" {
nodesReached.insert(name)
if nodesReached.count == self.nodes.count - 1 {
expectation.fulfill()
} }
} }
} }
// 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 // MARK: - Stress Tests
func testHighLoadScenario() { @Test func highLoadScenario() async throws {
connectFullMesh() helper.connectFullMesh()
let messagesPerNode = 25 let messagesPerNode = 25
let expectedTotal = messagesPerNode * nodes.count * (nodes.count - 1) let expectedTotal = messagesPerNode * helper.nodes.count * (helper.nodes.count - 1)
var receivedTotal = 0
let expectation = XCTestExpectation(description: "High load handled")
// Each node tracks messages await confirmation("High load handled", expectedCount: expectedTotal) { received in
for (_, node) in nodes { // Each node tracks messages
node.messageDeliveryHandler = { _ in for (_, node) in helper.nodes {
receivedTotal += 1 node.messageDeliveryHandler = { _ in
if receivedTotal >= (expectedTotal - 2) { received()
expectation.fulfill()
} }
} }
}
// All nodes send many messages simultaneously // All nodes send many messages simultaneously
DispatchQueue.concurrentPerform(iterations: nodes.count) { index in await withTaskGroup(of: Void.self) { group in
let nodeName = Array(nodes.keys).sorted()[index] for (name, node) in helper.nodes {
for i in 0..<messagesPerNode { group.addTask {
nodes[nodeName]!.sendMessage("\(nodeName) message \(i)", mentions: [], to: nil) 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() { @Test func mixedTrafficPatterns() async throws {
connectFullMesh() helper.connectFullMesh()
let expectation = XCTestExpectation(description: "Mixed traffic handled")
var metrics = [ var metrics = [
"public": 0, "public": 0,
"private": 0, "private": 0,
@@ -434,7 +379,7 @@ final class IntegrationTests: XCTestCase {
] ]
// Setup complex handlers // Setup complex handlers
for (name, node) in nodes { for (name, node) in helper.nodes {
node.messageDeliveryHandler = { message in node.messageDeliveryHandler = { message in
if message.isPrivate { if message.isPrivate {
metrics["private"]! += 1 metrics["private"]! += 1
@@ -453,222 +398,119 @@ final class IntegrationTests: XCTestCase {
} }
// Generate mixed traffic // Generate mixed traffic
nodes["Alice"]!.sendMessage("Public broadcast", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Public broadcast")
nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: TestConstants.testPeerID2, recipientNickname: "Bob") helper.nodes["Alice"]!.sendPrivateMessage("Private to Bob", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"], to: nil) helper.nodes["Bob"]!.sendMessage("Mentioning @Charlie", mentions: ["Charlie"])
// Disconnect to force relay // Disconnect to force relay
disconnect("Alice", "David") helper.disconnect("Alice", "David")
nodes["Alice"]!.sendMessage("Needs relay to David", mentions: [], to: nil) helper.nodes["Alice"]!.sendMessage("Needs relay to David")
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { #expect(metrics["public", default: 0] > 0)
XCTAssertGreaterThan(metrics["public"]!, 0) #expect(metrics["private", default: 0] > 0)
XCTAssertGreaterThan(metrics["private"]!, 0) #expect(metrics["mentions", default: 0] > 0)
XCTAssertGreaterThan(metrics["mentions"]!, 0)
expectation.fulfill()
}
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
} }
// MARK: - Security Integration Tests // MARK: - Security Integration Tests
// Replacement for the legacy NACK test: verifies that after a // Replacement for the legacy NACK test: verifies that after a
// decryption failure, peers can rehandshake via NoiseSessionManager // decryption failure, peers can rehandshake via NoiseSessionManager
// and resume secure communication. // and resume secure communication.
func testRehandshakeAfterDecryptionFailure() throws { @Test func rehandshakeAfterDecryptionFailure() throws {
// Alice <-> Bob connected // Alice <-> Bob connected
connect("Alice", "Bob") helper.connect("Alice", "Bob")
// Establish initial Noise session // Establish initial Noise session
try establishNoiseSession("Alice", "Bob") try helper.establishNoiseSession("Alice", "Bob")
guard let aliceManager = noiseManagers["Alice"], guard let aliceManager = helper.noiseManagers["Alice"],
let bobManager = noiseManagers["Bob"], let bobManager = helper.noiseManagers["Bob"],
let alicePeerID = nodes["Alice"]?.peerID, let alicePeerID = helper.nodes["Alice"]?.peerID,
let bobPeerID = nodes["Bob"]?.peerID else { let bobPeerID = helper.nodes["Bob"]?.peerID
return XCTFail("Missing managers or peer IDs") else {
Issue.record("Missing managers or peer IDs")
return
} }
// Baseline: encrypt from Alice, decrypt at Bob // Baseline: encrypt from Alice, decrypt at Bob
let plaintext1 = Data("hello-secure".utf8) let plaintext1 = Data("hello-secure".utf8)
let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID) let encrypted1 = try aliceManager.encrypt(plaintext1, for: bobPeerID)
let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID) let decrypted1 = try bobManager.decrypt(encrypted1, from: alicePeerID)
XCTAssertEqual(decrypted1, plaintext1) #expect(decrypted1 == plaintext1)
// Simulate decryption failure by corrupting ciphertext // Simulate decryption failure by corrupting ciphertext
var corrupted = encrypted1 let corrupted = encrypted1.prefix(15)
if !corrupted.isEmpty { corrupted[corrupted.count - 1] ^= 0xFF } #expect(throws: NoiseError.invalidCiphertext) {
do {
_ = try bobManager.decrypt(corrupted, from: alicePeerID) _ = 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 // Bob initiates a new handshake; clear Bob's session first so initiateHandshake won't throw
bobManager.removeSession(for: alicePeerID) bobManager.removeSession(for: alicePeerID)
try establishNoiseSession("Bob", "Alice") try helper.establishNoiseSession("Bob", "Alice")
// After rehandshake, encryption/decryption works again // After rehandshake, encryption/decryption works again
let plaintext2 = Data("hello-again".utf8) let plaintext2 = Data("hello-again".utf8)
let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID) let encrypted2 = try aliceManager.encrypt(plaintext2, for: bobPeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID) let decrypted2 = try bobManager.decrypt(encrypted2, from: alicePeerID)
XCTAssertEqual(decrypted2, plaintext2) #expect(decrypted2 == plaintext2)
} }
@Test func endToEndSecurityScenario() async throws {
func testEndToEndSecurityScenario() throws { helper.connect("Alice", "Bob")
connect("Alice", "Bob") helper.connect("Bob", "Charlie") // Charlie will try to eavesdrop
connect("Bob", "Charlie") // Charlie will try to eavesdrop
// Establish secure session between Alice and Bob only // Establish secure session between Alice and Bob only
try establishNoiseSession("Alice", "Bob") try helper.establishNoiseSession("Alice", "Bob")
let expectation = XCTestExpectation(description: "Secure communication maintained") await confirmation("Secure communication maintained", expectedCount: 2) { receivedPacket in
var bobDecrypted = false
var charlieIntercepted = false
// Setup encryption at Alice // Setup encryption at Alice
nodes["Alice"]!.packetDeliveryHandler = { packet in helper.nodes["Alice"]!.packetDeliveryHandler = { packet in
if packet.type == 0x01, if packet.type == 0x01,
let message = BitchatMessage(packet.payload), let message = BitchatMessage(packet.payload),
message.isPrivate && packet.recipientID != nil { message.isPrivate && packet.recipientID != nil {
// Encrypt private messages // Encrypt private messages
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) { if let encrypted = try? helper.noiseManagers["Alice"]!.encrypt(packet.payload, for: helper.nodes["Bob"]!.peerID) {
let encPacket = BitchatPacket( let encPacket = BitchatPacket(
type: 0x02, type: 0x02,
senderID: packet.senderID, senderID: packet.senderID,
recipientID: packet.recipientID, recipientID: packet.recipientID,
timestamp: packet.timestamp, timestamp: packet.timestamp,
payload: encrypted, payload: encrypted,
signature: packet.signature, signature: packet.signature,
ttl: packet.ttl ttl: packet.ttl
) )
self.nodes["Bob"]!.simulateIncomingPacket(encPacket) helper.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 }
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)
} }
} }
} }
// 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)
}
} }
@@ -0,0 +1,123 @@
//
// TestNetworkHelper.swift
// bitchatTests
//
// Extracted shared, mutable integration state for nodes and noise sessions.
// Keeps test containers nonmutating (Swift Testing-friendly).
//
import Foundation
import CryptoKit
@testable import bitchat
final class TestNetworkHelper {
// Public, read-only views for tests; mutation only through methods
var nodes: [String: MockBLEService] = [:]
var noiseManagers: [String: NoiseSessionManager] = [:]
let mockKeychain = MockKeychain()
private let bus = MockBLEBus(autoFloodEnabled: true)
// MARK: - Node/Manager management
@discardableResult
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
let node = MockBLEService(bus: bus)
node.myPeerID = peerID
node.mockNickname = name
nodes[name] = node
// Create/replace Noise manager for this node
let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
return node
}
func getNode(_ name: String) -> MockBLEService? {
nodes[name]
}
func getManager(_ name: String) -> NoiseSessionManager? {
noiseManagers[name]
}
// MARK: - Topology
func connect(_ a: String, _ b: String) {
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
n1.simulateConnectedPeer(n2.peerID)
n2.simulateConnectedPeer(n1.peerID)
}
func disconnect(_ a: String, _ b: String) {
guard let n1 = nodes[a], let n2 = nodes[b] else { return }
n1.simulateDisconnectedPeer(n2.peerID)
n2.simulateDisconnectedPeer(n1.peerID)
}
func connectFullMesh() {
let names = Array(nodes.keys)
for i in 0..<names.count {
for j in (i+1)..<names.count {
connect(names[i], names[j])
}
}
}
// MARK: - Relay
func setupRelay(_ nodeName: String, nextHops: [String]) {
guard let node = nodes[nodeName] else { return }
node.packetDeliveryHandler = { [weak self] packet in
guard let self else { return }
guard packet.ttl > 1 else { return }
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)
}
}
}
}
}
// MARK: - Noise sessions
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)
}
}
+18 -17
View File
@@ -1,8 +1,9 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class LocationChannelsTests: XCTestCase { struct LocationChannelsTests {
func testGeohashEncoderPrecisionMapping() { @Test func geohashEncoderPrecisionMapping() {
// Sanity: known coords (Statue of Liberty approx) // Sanity: known coords (Statue of Liberty approx)
let lat = 40.6892 let lat = 40.6892
let lon = -74.0445 let lon = -74.0445
@@ -12,35 +13,35 @@ final class LocationChannelsTests: XCTestCase {
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision) let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
XCTAssertEqual(block.count, 7) #expect(block.count == 7)
XCTAssertEqual(neighborhood.count, 6) #expect(neighborhood.count == 6)
XCTAssertEqual(city.count, 5) #expect(city.count == 5)
XCTAssertEqual(region.count, 4) #expect(region.count == 4)
XCTAssertEqual(country.count, 2) #expect(country.count == 2)
// All prefixes must match progressively // All prefixes must match progressively
XCTAssertTrue(block.hasPrefix(neighborhood)) #expect(block.hasPrefix(neighborhood))
XCTAssertTrue(neighborhood.hasPrefix(city)) #expect(neighborhood.hasPrefix(city))
XCTAssertTrue(city.hasPrefix(region)) #expect(city.hasPrefix(region))
XCTAssertTrue(region.hasPrefix(country)) #expect(region.hasPrefix(country))
} }
func testNostrGeohashFilterEncoding() throws { @Test func nostrGeohashFilterEncoding() throws {
let gh = "u4pruy" let gh = "u4pruy"
let filter = NostrFilter.geohashEphemeral(gh) let filter = NostrFilter.geohashEphemeral(gh)
let data = try JSONEncoder().encode(filter) let data = try JSONEncoder().encode(filter)
let json = String(data: data, encoding: .utf8) ?? "" let json = String(data: data, encoding: .utf8) ?? ""
// Expect kinds includes 20000 and tag filter '#g':[gh] // Expect kinds includes 20000 and tag filter '#g':[gh]
XCTAssertTrue(json.contains("20000")) #expect(json.contains("20000"))
XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]")) #expect(json.contains("\"#g\":[\"\(gh)\"]"))
} }
func testPerGeohashIdentityDeterministic() throws { @Test func perGeohashIdentityDeterministic() throws {
// Derive twice for same geohash; should be identical // Derive twice for same geohash; should be identical
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let gh = "u4pruy" let gh = "u4pruy"
let id1 = try idBridge.deriveIdentity(forGeohash: gh) let id1 = try idBridge.deriveIdentity(forGeohash: gh)
let id2 = try idBridge.deriveIdentity(forGeohash: gh) let id2 = try idBridge.deriveIdentity(forGeohash: gh)
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex) #expect(id1.publicKeyHex == id2.publicKeyHex)
} }
} }
+24 -23
View File
@@ -1,8 +1,9 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
@MainActor @MainActor
final class LocationNotesManagerTests: XCTestCase { struct LocationNotesManagerTests {
// func testSubscribeWithoutRelaysSetsNoRelaysState() { // func testSubscribeWithoutRelaysSetsNoRelaysState() {
// var subscribeCalled = false // var subscribeCalled = false
// let deps = LocationNotesDependencies( // let deps = LocationNotesDependencies(
@@ -47,15 +48,15 @@ final class LocationNotesManagerTests: XCTestCase {
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays") // XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
// } // }
func testSubscribeUsesGeoRelaysAndAppendsNotes() { @Test func subscribeUsesGeoRelaysAndAppendsNotes() {
var relaysCaptured: [String] = [] var relaysCaptured: [String] = []
var storedHandler: ((NostrEvent) -> Void)? var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)? var storedEOSE: (() -> Void)?
let deps = LocationNotesDependencies( let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] }, relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { filter, id, relays, handler, eose in subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(filter.kinds, [1]) #expect(filter.kinds == [1])
XCTAssertFalse(id.isEmpty) #expect(!id.isEmpty)
relaysCaptured = relays relaysCaptured = relays
storedHandler = handler storedHandler = handler
storedEOSE = eose storedEOSE = eose
@@ -67,8 +68,8 @@ final class LocationNotesManagerTests: XCTestCase {
) )
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps) let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
XCTAssertEqual(relaysCaptured, ["wss://relay.one"]) #expect(relaysCaptured == ["wss://relay.one"])
XCTAssertEqual(manager.state, .loading) #expect(manager.state == .loading)
var event = NostrEvent( var event = NostrEvent(
pubkey: "pub", pubkey: "pub",
@@ -81,9 +82,9 @@ final class LocationNotesManagerTests: XCTestCase {
storedHandler?(event) storedHandler?(event)
storedEOSE?() storedEOSE?()
XCTAssertEqual(manager.state, .ready) #expect(manager.state == .ready)
XCTAssertEqual(manager.notes.count, 1) #expect(manager.notes.count == 1)
XCTAssertEqual(manager.notes.first?.content, "hi") #expect(manager.notes.first?.content == "hi")
} }
private enum TestError: Error { private enum TestError: Error {
@@ -92,8 +93,8 @@ final class LocationNotesManagerTests: XCTestCase {
} }
@MainActor @MainActor
final class LocationNotesCounterTests: XCTestCase { struct LocationNotesCounterTests {
func testSubscribeWithoutRelaysMarksUnavailable() { @Test func subscribeWithoutRelaysMarksUnavailable() {
var subscribeCalled = false var subscribeCalled = false
let deps = LocationNotesCounterDependencies( let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in [] }, relayLookup: { _, _ in [] },
@@ -104,21 +105,21 @@ final class LocationNotesCounterTests: XCTestCase {
let counter = LocationNotesCounter(testDependencies: deps) let counter = LocationNotesCounter(testDependencies: deps)
counter.subscribe(geohash: "u4pruydq") counter.subscribe(geohash: "u4pruydq")
XCTAssertFalse(subscribeCalled) #expect(!subscribeCalled)
XCTAssertFalse(counter.relayAvailable) #expect(!counter.relayAvailable)
XCTAssertTrue(counter.initialLoadComplete) #expect(counter.initialLoadComplete)
XCTAssertEqual(counter.count, 0) #expect(counter.count == 0)
} }
func testSubscribeCountsUniqueNotes() { @Test func subscribeCountsUniqueNotes() {
var storedHandler: ((NostrEvent) -> Void)? var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)? var storedEOSE: (() -> Void)?
let deps = LocationNotesCounterDependencies( let deps = LocationNotesCounterDependencies(
relayLookup: { _, _ in ["wss://relay.geo"] }, relayLookup: { _, _ in ["wss://relay.geo"] },
subscribe: { filter, id, relays, handler, eose in subscribe: { filter, id, relays, handler, eose in
XCTAssertEqual(relays, ["wss://relay.geo"]) #expect(relays == ["wss://relay.geo"])
XCTAssertEqual(filter.kinds, [1]) #expect(filter.kinds == [1])
XCTAssertFalse(id.isEmpty) #expect(!id.isEmpty)
storedHandler = handler storedHandler = handler
storedEOSE = eose storedEOSE = eose
}, },
@@ -143,8 +144,8 @@ final class LocationNotesCounterTests: XCTestCase {
storedEOSE?() storedEOSE?()
XCTAssertTrue(counter.relayAvailable) #expect(counter.relayAvailable)
XCTAssertEqual(counter.count, 1) #expect(counter.count == 1)
XCTAssertTrue(counter.initialLoadComplete) #expect(counter.initialLoadComplete)
} }
} }
+57
View File
@@ -0,0 +1,57 @@
//
// MockBLEBus.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@testable import bitchat
final class MockBLEBus {
private var registry: [PeerID: MockBLEService] = [:]
private var adjacency: [PeerID: Set<PeerID>] = [:]
// Enable automatic flooding for public messages in integration tests only
let autoFloodEnabled: Bool
init(autoFloodEnabled: Bool = false) {
self.autoFloodEnabled = autoFloodEnabled
}
func register(_ service: MockBLEService, for peerID: PeerID) {
registry[peerID] = service
if adjacency[peerID] == nil { adjacency[peerID] = [] }
}
func connect(_ a: PeerID, _ b: PeerID) {
var setA = adjacency[a] ?? []
setA.insert(b)
adjacency[a] = setA
var setB = adjacency[b] ?? []
setB.insert(a)
adjacency[b] = setB
}
func disconnect(_ a: PeerID, _ b: PeerID) {
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
}
func neighbors(of peerID: PeerID) -> [MockBLEService] {
let ids = adjacency[peerID] ?? []
let result = ids.compactMap { registry[$0] }
return result
}
func isDirectNeighbor(_ a: PeerID, _ b: PeerID) -> Bool {
let res = adjacency[a]?.contains(b) ?? false
return res
}
func service(for peerID: PeerID) -> MockBLEService? {
let svc = registry[peerID]
return svc
}
}
+17 -49
View File
@@ -26,13 +26,12 @@ import CoreBluetooth
/// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit /// simulate broadcast propagation across the mesh. E2E tests keep it off and perform explicit
/// relays when needed. /// relays when needed.
final class MockBLEService: NSObject { final class MockBLEService: NSObject {
// Enable automatic flooding for public messages in integration tests only private let bus: MockBLEBus
static var autoFloodEnabled: Bool = false
// MARK: - Properties matching BLEService // MARK: - Properties matching BLEService
weak var delegate: BitchatDelegate? weak var delegate: BitchatDelegate?
var myPeerID: PeerID = "MOCK1234" var myPeerID = PeerID(str: "MOCK1234")
var myNickname: String = "MockUser" var myNickname: String = "MockUser"
private let mockKeychain = MockKeychain() private let mockKeychain = MockKeychain()
@@ -60,8 +59,8 @@ final class MockBLEService: NSObject {
// MARK: - Initialization // MARK: - Initialization
override init() { init(bus: MockBLEBus) {
super.init() self.bus = bus
} }
// MARK: - Methods matching BLEService // MARK: - Methods matching BLEService
@@ -71,42 +70,15 @@ final class MockBLEService: NSObject {
} }
// MARK: - In-memory test bus (for E2E/Integration) // MARK: - In-memory test bus (for E2E/Integration)
/// Global per-process bus for deterministic routing in tests.
private static var registry: [PeerID: MockBLEService] = [:]
private static var adjacency: [PeerID: Set<PeerID>] = [:]
/// Clears global bus state. Call from test `setUp()`.
static func resetTestBus() {
registry.removeAll()
adjacency.removeAll()
}
/// Registers this instance on first use. /// Registers this instance on first use.
private func registerIfNeeded() { private func registerIfNeeded() {
MockBLEService.registry[myPeerID] = self bus.register(self, for: myPeerID)
if MockBLEService.adjacency[myPeerID] == nil { MockBLEService.adjacency[myPeerID] = [] }
} }
/// Returns adjacent neighbors based on the current simulated topology. /// Returns adjacent neighbors based on the current simulated topology.
private func neighbors() -> [MockBLEService] { private func neighbors() -> [MockBLEService] {
guard let ids = MockBLEService.adjacency[myPeerID] else { return [] } bus.neighbors(of: myPeerID)
return ids.compactMap { MockBLEService.registry[$0] }
}
/// Adds an undirected edge between two peerIDs.
private static func connectPeers(_ a: PeerID, _ b: PeerID) {
var setA = adjacency[a] ?? []
setA.insert(b)
adjacency[a] = setA
var setB = adjacency[b] ?? []
setB.insert(a)
adjacency[b] = setB
}
/// Removes an undirected edge between two peerIDs.
private static func disconnectPeers(_ a: PeerID, _ b: PeerID) {
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
} }
func startServices() { func startServices() {
@@ -173,7 +145,7 @@ final class MockBLEService: NSObject {
// Surface raw packet to tests that intercept/relay/encrypt // Surface raw packet to tests that intercept/relay/encrypt
packetDeliveryHandler?(packet) packetDeliveryHandler?(packet)
// Deliver public messages to adjacent peers via test bus // Deliver public messages to adjacent peers via bus
if recipientID == nil { if recipientID == nil {
for neighbor in neighbors() { for neighbor in neighbors() {
neighbor.simulateIncomingPacket(packet) neighbor.simulateIncomingPacket(packet)
@@ -219,20 +191,16 @@ final class MockBLEService: NSObject {
packetDeliveryHandler?(packet) packetDeliveryHandler?(packet)
// If directly connected to recipient, deliver only to them. // If directly connected to recipient, deliver only to them.
if let neighbors = MockBLEService.adjacency[myPeerID], neighbors.contains(recipientPeerID), if bus.isDirectNeighbor(myPeerID, recipientPeerID),
let target = MockBLEService.registry[recipientPeerID] { let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet) target.simulateIncomingPacket(packet)
} else { } else {
// Not directly connected: deliver to neighbors for relay; also deliver directly if target is known // Not directly connected: deliver to neighbors for relay; also deliver directly if target is known
if let target = MockBLEService.registry[recipientPeerID] { if let target = bus.service(for: recipientPeerID) {
target.simulateIncomingPacket(packet) target.simulateIncomingPacket(packet)
} }
if let neighbors = MockBLEService.adjacency[myPeerID] { for neighbor in neighbors() where neighbor.peerID != recipientPeerID {
for peer in neighbors where peer != recipientPeerID { neighbor.simulateIncomingPacket(packet)
if let neighbor = MockBLEService.registry[peer] {
neighbor.simulateIncomingPacket(packet)
}
}
} }
} }
} }
@@ -279,14 +247,14 @@ final class MockBLEService: NSObject {
func simulateConnectedPeer(_ peerID: PeerID) { func simulateConnectedPeer(_ peerID: PeerID) {
registerIfNeeded() registerIfNeeded()
MockBLEService.connectPeers(myPeerID, peerID) bus.connect(myPeerID, peerID)
connectedPeers.insert(peerID) connectedPeers.insert(peerID)
delegate?.didConnectToPeer(peerID) delegate?.didConnectToPeer(peerID)
delegate?.didUpdatePeerList(Array(connectedPeers)) delegate?.didUpdatePeerList(Array(connectedPeers))
} }
func simulateDisconnectedPeer(_ peerID: PeerID) { func simulateDisconnectedPeer(_ peerID: PeerID) {
MockBLEService.disconnectPeers(myPeerID, peerID) bus.disconnect(myPeerID, peerID)
connectedPeers.remove(peerID) connectedPeers.remove(peerID)
delegate?.didDisconnectFromPeer(peerID) delegate?.didDisconnectFromPeer(peerID)
delegate?.didUpdatePeerList(Array(connectedPeers)) delegate?.didUpdatePeerList(Array(connectedPeers))
@@ -319,7 +287,7 @@ final class MockBLEService: NSObject {
// When enabled, propagate a public broadcast across the entire connected // When enabled, propagate a public broadcast across the entire connected
// component regardless of the original TTL to better emulate large-network // component regardless of the original TTL to better emulate large-network
// broadcast expectations. De-duplication via seenMessageIDs prevents loops. // broadcast expectations. De-duplication via seenMessageIDs prevents loops.
if MockBLEService.autoFloodEnabled, if bus.autoFloodEnabled,
packet.recipientID == nil, packet.recipientID == nil,
!message.isPrivate { !message.isPrivate {
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0 let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
@@ -353,8 +321,8 @@ typealias MockSimplifiedBluetoothService = MockBLEService
// MARK: - Helpers // MARK: - Helpers
extension MockBLEService { extension MockBLEService {
convenience init(peerID: PeerID, nickname: String) { convenience init(peerID: PeerID, nickname: String, bus: MockBLEBus) {
self.init() self.init(bus: bus)
myPeerID = peerID myPeerID = peerID
mockNickname = nickname mockNickname = nickname
} }
+211 -246
View File
@@ -6,135 +6,123 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import CryptoKit import CryptoKit
import Foundation
@testable import bitchat @testable import bitchat
final class NoiseProtocolTests: XCTestCase { struct NoiseProtocolTests {
var aliceKey: Curve25519.KeyAgreement.PrivateKey! private let aliceKey = Curve25519.KeyAgreement.PrivateKey()
var bobKey: Curve25519.KeyAgreement.PrivateKey! private let bobKey = Curve25519.KeyAgreement.PrivateKey()
var aliceSession: NoiseSession! private let mockKeychain = MockKeychain()
var bobSession: NoiseSession!
private var mockKeychain: MockKeychain!
override func setUp() { private let alicePeerID = PeerID(str: UUID().uuidString)
super.setUp() private let bobPeerID = PeerID(str: UUID().uuidString)
aliceKey = Curve25519.KeyAgreement.PrivateKey()
bobKey = Curve25519.KeyAgreement.PrivateKey()
mockKeychain = MockKeychain()
}
override func tearDown() { private let aliceSession: NoiseSession
aliceSession = nil private let bobSession: NoiseSession
bobSession = nil
mockKeychain = nil
super.tearDown()
}
// MARK: - Basic Handshake Tests init() {
func testXXPatternHandshake() throws {
// Create sessions
aliceSession = NoiseSession( aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2, peerID: alicePeerID,
role: .initiator, role: .initiator,
keychain: mockKeychain, keychain: mockKeychain,
localStaticKey: aliceKey localStaticKey: aliceKey
) )
bobSession = NoiseSession( bobSession = NoiseSession(
peerID: TestConstants.testPeerID1, peerID: bobPeerID,
role: .responder, role: .responder,
keychain: mockKeychain, keychain: mockKeychain,
localStaticKey: bobKey localStaticKey: bobKey
) )
}
// MARK: - Basic Handshake Tests
@Test func xxPatternHandshake() throws {
// Alice starts handshake (message 1) // Alice starts handshake (message 1)
let message1 = try aliceSession.startHandshake() let message1 = try aliceSession.startHandshake()
XCTAssertFalse(message1.isEmpty) #expect(!message1.isEmpty)
XCTAssertEqual(aliceSession.getState(), .handshaking) #expect(aliceSession.getState() == .handshaking)
// Bob processes message 1 and creates message 2 // Bob processes message 1 and creates message 2
let message2 = try bobSession.processHandshakeMessage(message1) let message2 = try bobSession.processHandshakeMessage(message1)
XCTAssertNotNil(message2) #expect(message2 != nil)
XCTAssertFalse(message2!.isEmpty) #expect(!message2!.isEmpty)
XCTAssertEqual(bobSession.getState(), .handshaking) #expect(bobSession.getState() == .handshaking)
// Alice processes message 2 and creates message 3 // Alice processes message 2 and creates message 3
let message3 = try aliceSession.processHandshakeMessage(message2!) let message3 = try aliceSession.processHandshakeMessage(message2!)
XCTAssertNotNil(message3) #expect(message3 != nil)
XCTAssertFalse(message3!.isEmpty) #expect(!message3!.isEmpty)
XCTAssertEqual(aliceSession.getState(), .established) #expect(aliceSession.getState() == .established)
// Bob processes message 3 and completes handshake // Bob processes message 3 and completes handshake
let finalMessage = try bobSession.processHandshakeMessage(message3!) let finalMessage = try bobSession.processHandshakeMessage(message3!)
XCTAssertNil(finalMessage) // No more messages needed #expect(finalMessage == nil) // No more messages needed
XCTAssertEqual(bobSession.getState(), .established) #expect(bobSession.getState() == .established)
// Verify both sessions are established // Verify both sessions are established
XCTAssertTrue(aliceSession.isEstablished()) #expect(aliceSession.isEstablished())
XCTAssertTrue(bobSession.isEstablished()) #expect(bobSession.isEstablished())
// Verify they have each other's static keys // Verify they have each other's static keys
XCTAssertEqual(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation, bobKey.publicKey.rawRepresentation) #expect(aliceSession.getRemoteStaticPublicKey()?.rawRepresentation == bobKey.publicKey.rawRepresentation)
XCTAssertEqual(bobSession.getRemoteStaticPublicKey()?.rawRepresentation, aliceKey.publicKey.rawRepresentation) #expect(bobSession.getRemoteStaticPublicKey()?.rawRepresentation == aliceKey.publicKey.rawRepresentation)
} }
func testHandshakeStateValidation() throws { @Test func handshakeStateValidation() throws {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
// Cannot process message before starting handshake // Cannot process message before starting handshake
XCTAssertThrowsError(try aliceSession.processHandshakeMessage(Data())) #expect(throws: NoiseSessionError.invalidState) {
try aliceSession.processHandshakeMessage(Data())
}
// Start handshake // Start handshake
_ = try aliceSession.startHandshake() _ = try aliceSession.startHandshake()
// Cannot start handshake twice // Cannot start handshake twice
XCTAssertThrowsError(try aliceSession.startHandshake()) #expect(throws: NoiseSessionError.invalidState) {
try aliceSession.startHandshake()
}
} }
// MARK: - Encryption/Decryption Tests // MARK: - Encryption/Decryption Tests
func testBasicEncryptionDecryption() throws { @Test func basicEncryptionDecryption() throws {
// Establish sessions try performHandshake(initiator: aliceSession, responder: bobSession)
try establishSessions()
let plaintext = "Hello, Bob!".data(using: .utf8)! let plaintext = "Hello, Bob!".data(using: .utf8)!
// Alice encrypts // Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
XCTAssertNotEqual(ciphertext, plaintext) #expect(ciphertext != plaintext)
XCTAssertGreaterThan(ciphertext.count, plaintext.count) // Should have overhead #expect(ciphertext.count > plaintext.count) // Should have overhead
// Bob decrypts // Bob decrypts
let decrypted = try bobSession.decrypt(ciphertext) let decrypted = try bobSession.decrypt(ciphertext)
XCTAssertEqual(decrypted, plaintext) #expect(decrypted == plaintext)
} }
func testBidirectionalEncryption() throws { @Test func bidirectionalEncryption() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob // Alice -> Bob
let aliceMessage = "Hello from Alice".data(using: .utf8)! let aliceMessage = "Hello from Alice".data(using: .utf8)!
let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext) let bobReceived = try bobSession.decrypt(aliceCiphertext)
XCTAssertEqual(bobReceived, aliceMessage) #expect(bobReceived == aliceMessage)
// Bob -> Alice // Bob -> Alice
let bobMessage = "Hello from Bob".data(using: .utf8)! let bobMessage = "Hello from Bob".data(using: .utf8)!
let bobCiphertext = try bobSession.encrypt(bobMessage) let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext) let aliceReceived = try aliceSession.decrypt(bobCiphertext)
XCTAssertEqual(aliceReceived, bobMessage) #expect(aliceReceived == bobMessage)
} }
func testLargeMessageEncryption() throws { @Test func largeMessageEncryption() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
// Create a large message // Create a large message
let largeMessage = TestHelpers.generateRandomData(length: 100_000) let largeMessage = TestHelpers.generateRandomData(length: 100_000)
@@ -143,81 +131,78 @@ final class NoiseProtocolTests: XCTestCase {
let ciphertext = try aliceSession.encrypt(largeMessage) let ciphertext = try aliceSession.encrypt(largeMessage)
let decrypted = try bobSession.decrypt(ciphertext) let decrypted = try bobSession.decrypt(ciphertext)
XCTAssertEqual(decrypted, largeMessage) #expect(decrypted == largeMessage)
} }
func testEncryptionBeforeHandshake() { @Test func encryptionBeforeHandshake() {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
let plaintext = "test".data(using: .utf8)! let plaintext = "test".data(using: .utf8)!
// Should throw when not established #expect(throws: NoiseSessionError.notEstablished) {
XCTAssertThrowsError(try aliceSession.encrypt(plaintext)) try aliceSession.encrypt(plaintext)
XCTAssertThrowsError(try aliceSession.decrypt(plaintext)) }
#expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.decrypt(plaintext)
}
} }
// MARK: - Session Manager Tests // MARK: - Session Manager Tests
func testSessionManagerBasicOperations() throws { @Test func sessionManagerBasicOperations() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
XCTAssertNil(manager.getSession(for: TestConstants.testPeerID2)) #expect(manager.getSession(for: alicePeerID) == nil)
_ = try manager.initiateHandshake(with: TestConstants.testPeerID2) _ = try manager.initiateHandshake(with: alicePeerID)
XCTAssertNotNil(manager.getSession(for: TestConstants.testPeerID2)) #expect(manager.getSession(for: alicePeerID) != nil)
// Get session // Get session
let retrieved = manager.getSession(for: TestConstants.testPeerID2) let retrieved = manager.getSession(for: alicePeerID)
XCTAssertNotNil(retrieved) #expect(retrieved != nil)
// Remove session // Remove session
manager.removeSession(for: TestConstants.testPeerID2) manager.removeSession(for: alicePeerID)
XCTAssertNil(manager.getSession(for: TestConstants.testPeerID2)) #expect(manager.getSession(for: alicePeerID) == nil)
} }
func testSessionManagerHandshakeInitiation() throws { @Test func sessionManagerHandshakeInitiation() throws {
let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let manager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
// Initiate handshake // Initiate handshake
let handshakeData = try manager.initiateHandshake(with: TestConstants.testPeerID2) let handshakeData = try manager.initiateHandshake(with: alicePeerID)
XCTAssertFalse(handshakeData.isEmpty) #expect(!handshakeData.isEmpty)
// Session should exist // Session should exist
let session = manager.getSession(for: TestConstants.testPeerID2) let session = manager.getSession(for: alicePeerID)
XCTAssertNotNil(session) #expect(session != nil)
XCTAssertEqual(session?.getState(), .handshaking) #expect(session?.getState() == .handshaking)
} }
func testSessionManagerIncomingHandshake() throws { @Test func sessionManagerIncomingHandshake() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Alice initiates // Alice initiates
let message1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
// Bob responds // Bob responds
let message2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message1) let message2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
XCTAssertNotNil(message2) #expect(message2 != nil)
// Continue handshake // Continue handshake
let message3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: message2!) let message3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2!)
XCTAssertNotNil(message3) #expect(message3 != nil)
// Complete handshake // Complete handshake
let finalMessage = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: message3!) let finalMessage = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3!)
XCTAssertNil(finalMessage) #expect(finalMessage == nil)
// Both should have established sessions // Both should have established sessions
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false) #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
} }
func testSessionManagerEncryptionDecryption() throws { @Test func sessionManagerEncryptionDecryption() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -226,17 +211,17 @@ final class NoiseProtocolTests: XCTestCase {
// Encrypt with manager // Encrypt with manager
let plaintext = "Test message".data(using: .utf8)! let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2) let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager // Decrypt with manager
let decrypted = try bobManager.decrypt(ciphertext, from: TestConstants.testPeerID1) let decrypted = try bobManager.decrypt(ciphertext, from: bobPeerID)
XCTAssertEqual(decrypted, plaintext) #expect(decrypted == plaintext)
} }
// MARK: - Security Tests // MARK: - Security Tests
func testTamperedCiphertextDetection() throws { @Test func tamperedCiphertextDetection() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Secret message".data(using: .utf8)! let plaintext = "Secret message".data(using: .utf8)!
var ciphertext = try aliceSession.encrypt(plaintext) var ciphertext = try aliceSession.encrypt(plaintext)
@@ -245,11 +230,19 @@ final class NoiseProtocolTests: XCTestCase {
ciphertext[ciphertext.count / 2] ^= 0xFF ciphertext[ciphertext.count / 2] ^= 0xFF
// Decryption should fail // Decryption should fail
XCTAssertThrowsError(try bobSession.decrypt(ciphertext)) if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobSession.decrypt(ciphertext)
}
} else {
#expect(throws: (any Error).self) {
try bobSession.decrypt(ciphertext)
}
}
} }
func testReplayPrevention() throws { @Test func replayPrevention() throws {
try establishSessions() try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = "Test message".data(using: .utf8)! let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
@@ -258,16 +251,18 @@ final class NoiseProtocolTests: XCTestCase {
_ = try bobSession.decrypt(ciphertext) _ = try bobSession.decrypt(ciphertext)
// Replaying the same ciphertext should fail // Replaying the same ciphertext should fail
XCTAssertThrowsError(try bobSession.decrypt(ciphertext)) #expect(throws: NoiseError.replayDetected) {
try bobSession.decrypt(ciphertext)
}
} }
func testSessionIsolation() throws { @Test func sessionIsolation() throws {
// Create two separate session pairs // Create two separate session pairs
let aliceSession1 = NoiseSession(peerID: "peer1", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession1 = NoiseSession(peerID: PeerID(str: "peer1"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession1 = NoiseSession(peerID: "alice1", role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession1 = NoiseSession(peerID: PeerID(str: "alice1"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
let aliceSession2 = NoiseSession(peerID: "peer2", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession2 = NoiseSession(peerID: PeerID(str: "peer2"), role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bobSession2 = NoiseSession(peerID: "alice2", role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession2 = NoiseSession(peerID: PeerID(str: "alice2"), role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish both pairs // Establish both pairs
try performHandshake(initiator: aliceSession1, responder: bobSession1) try performHandshake(initiator: aliceSession1, responder: bobSession1)
@@ -278,16 +273,24 @@ final class NoiseProtocolTests: XCTestCase {
let ciphertext1 = try aliceSession1.encrypt(plaintext) let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2 // Should not be able to decrypt with session 2
XCTAssertThrowsError(try bobSession2.decrypt(ciphertext1)) if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobSession2.decrypt(ciphertext1)
}
} else {
#expect(throws: (any Error).self) {
try bobSession2.decrypt(ciphertext1)
}
}
// But should work with correct session // But should work with correct session
let decrypted = try bobSession1.decrypt(ciphertext1) let decrypted = try bobSession1.decrypt(ciphertext1)
XCTAssertEqual(decrypted, plaintext) #expect(decrypted == plaintext)
} }
// MARK: - Session Recovery Tests // MARK: - Session Recovery Tests
func testPeerRestartDetection() throws { @Test func peerRestartDetection() throws {
// Establish initial sessions // Establish initial sessions
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -295,38 +298,38 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state // Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: TestConstants.testPeerID2) let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(message1, from: TestConstants.testPeerID1) _ = try bobManager.decrypt(message1, from: bobPeerID)
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: TestConstants.testPeerID1) let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
_ = try aliceManager.decrypt(message2, from: TestConstants.testPeerID2) _ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key // Simulate Bob restart by creating new manager with same key
let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManagerRestarted = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Bob initiates new handshake after restart // Bob initiates new handshake after restart
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: TestConstants.testPeerID1) let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
// Alice should accept the new handshake (clearing old session) // Alice should accept the new handshake (clearing old session)
let newHandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake1) let newHandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake1)
XCTAssertNotNil(newHandshake2) #expect(newHandshake2 != nil)
// Complete the new handshake // Complete the new handshake
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake2!) let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(from: bobPeerID, message: newHandshake2!)
XCTAssertNotNil(newHandshake3) #expect(newHandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions // Should be able to exchange messages with new sessions
let testMessage = "After restart".data(using: .utf8)! let testMessage = "After restart".data(using: .utf8)!
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: TestConstants.testPeerID1) let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: TestConstants.testPeerID2) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
XCTAssertEqual(decrypted, testMessage) #expect(decrypted == testMessage)
} }
func testNonceDesynchronizationRecovery() throws { @Test func nonceDesynchronizationRecovery() throws {
// Create two sessions // Create two sessions
aliceSession = NoiseSession(peerID: TestConstants.testPeerID2, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey) let aliceSession = NoiseSession(peerID: alicePeerID, role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
bobSession = NoiseSession(peerID: TestConstants.testPeerID1, role: .responder, keychain: mockKeychain, localStaticKey: bobKey) let bobSession = NoiseSession(peerID: bobPeerID, role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
// Establish sessions // Establish sessions
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
@@ -344,10 +347,12 @@ final class NoiseProtocolTests: XCTestCase {
// With per-packet nonce carried, decryption should not throw here // With per-packet nonce carried, decryption should not throw here
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!) let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
XCTAssertNoThrow(try bobSession.decrypt(desyncMessage)) #expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage)
}
} }
func testConcurrentEncryption() throws { @Test func concurrentEncryption() async throws {
// Test thread safety of encryption operations // Test thread safety of encryption operations
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -355,37 +360,35 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let messageCount = 100 let messageCount = 100
let expectation = XCTestExpectation(description: "All messages encrypted and decrypted")
expectation.expectedFulfillmentCount = messageCount
var encryptedMessages: [Int: Data] = [:] try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
// Encrypt messages sequentially to avoid nonce races in manager var encryptedMessages: [Int: Data] = [:]
for i in 0..<messageCount { // Encrypt messages sequentially to avoid nonce races in manager
let plaintext = "Concurrent message \(i)".data(using: .utf8)! for i in 0..<messageCount {
let encrypted = try aliceManager.encrypt(plaintext, for: TestConstants.testPeerID2) let plaintext = "Concurrent message \(i)".data(using: .utf8)!
encryptedMessages[i] = encrypted let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
} encryptedMessages[i] = encrypted
}
// Decrypt messages sequentially to avoid triggering anti-replay with reordering // Decrypt messages sequentially to avoid triggering anti-replay with reordering
for i in 0..<messageCount { for i in 0..<messageCount {
do { do {
guard let encrypted = encryptedMessages[i] else { guard let encrypted = encryptedMessages[i] else {
XCTFail("Missing encrypted message \(i)") Issue.record("Missing encrypted message \(i)")
return return
}
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = "Concurrent message \(i)".data(using: .utf8)!
#expect(decrypted == expected)
completion()
} catch {
Issue.record("Decryption failed for message \(i): \(error)")
} }
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)
let expected = "Concurrent message \(i)".data(using: .utf8)!
XCTAssertEqual(decrypted, expected)
expectation.fulfill()
} catch {
XCTFail("Decryption failed for message \(i): \(error)")
} }
} }
wait(for: [expectation], timeout: 10.0)
} }
func testSessionStaleDetection() throws { @Test func sessionStaleDetection() throws {
// Test that sessions are properly marked as stale // Test that sessions are properly marked as stale
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -396,10 +399,10 @@ final class NoiseProtocolTests: XCTestCase {
let sessions = aliceManager.getSessionsNeedingRekey() let sessions = aliceManager.getSessionsNeedingRekey()
// New session should not need rekey // New session should not need rekey
XCTAssertTrue(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey }) #expect(sessions.isEmpty || sessions.allSatisfy { !$0.needsRekey })
} }
func testHandshakeAfterDecryptionFailure() throws { @Test func handshakeAfterDecryptionFailure() throws {
// Test that handshake is properly initiated after decryption failure // Test that handshake is properly initiated after decryption failure
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -408,17 +411,25 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message // Create a corrupted message
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: TestConstants.testPeerID2) var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
encrypted[10] ^= 0xFF // Corrupt the data encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail // Decryption should fail
XCTAssertThrowsError(try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1)) if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try bobManager.decrypt(encrypted, from: bobPeerID)
}
} else {
#expect(throws: (any Error).self) {
try bobManager.decrypt(encrypted, from: bobPeerID)
}
}
// Bob should still have the session (it's not removed on single failure) // Bob should still have the session (it's not removed on single failure)
XCTAssertNotNil(bobManager.getSession(for: TestConstants.testPeerID1)) #expect(bobManager.getSession(for: bobPeerID) != nil)
} }
func testHandshakeAlwaysAcceptedWithExistingSession() throws { @Test func handshakeAlwaysAcceptedWithExistingSession() throws {
// Test that handshake is always accepted even with existing valid session // Test that handshake is always accepted even with existing valid session
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -427,38 +438,38 @@ final class NoiseProtocolTests: XCTestCase {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Verify sessions are established // Verify sessions are established
XCTAssertTrue(aliceManager.getSession(for: TestConstants.testPeerID2)?.isEstablished() ?? false) #expect(aliceManager.getSession(for: alicePeerID)?.isEstablished() == true)
XCTAssertTrue(bobManager.getSession(for: TestConstants.testPeerID1)?.isEstablished() ?? false) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work // Exchange messages to verify sessions work
let testMessage = "Session works".data(using: .utf8)! let testMessage = "Session works".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(testMessage, for: TestConstants.testPeerID2) let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: TestConstants.testPeerID1) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
XCTAssertEqual(decrypted, testMessage) #expect(decrypted == testMessage)
// Alice clears her session (simulating decryption failure) // Alice clears her session (simulating decryption failure)
aliceManager.removeSession(for: TestConstants.testPeerID2) aliceManager.removeSession(for: alicePeerID)
// Alice initiates new handshake despite Bob having valid session // Alice initiates new handshake despite Bob having valid session
let newHandshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let newHandshake1 = try aliceManager.initiateHandshake(with: alicePeerID)
// Bob should accept the new handshake even though he has a valid session // Bob should accept the new handshake even though he has a valid session
let newHandshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake1) let newHandshake2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake1)
XCTAssertNotNil(newHandshake2, "Bob should accept handshake despite having valid session") #expect(newHandshake2 != nil, "Bob should accept handshake despite having valid session")
// Complete the handshake // Complete the handshake
let newHandshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: newHandshake2!) let newHandshake3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake2!)
XCTAssertNotNil(newHandshake3) #expect(newHandshake3 != nil)
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: newHandshake3!) _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work // Verify new sessions work
let testMessage2 = "New session works".data(using: .utf8)! let testMessage2 = "New session works".data(using: .utf8)!
let encrypted2 = try aliceManager.encrypt(testMessage2, for: TestConstants.testPeerID2) let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: TestConstants.testPeerID1) let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
XCTAssertEqual(decrypted2, testMessage2) #expect(decrypted2 == testMessage2)
} }
func testNonceDesynchronizationCausesRehandshake() throws { @Test func nonceDesynchronizationCausesRehandshake() throws {
// Test that nonce desynchronization leads to proper re-handshake // Test that nonce desynchronization leads to proper re-handshake
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain) let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain) let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
@@ -468,89 +479,43 @@ final class NoiseProtocolTests: XCTestCase {
// Exchange messages normally // Exchange messages normally
for i in 0..<5 { for i in 0..<5 {
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2) let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(msg, from: TestConstants.testPeerID1) _ = try bobManager.decrypt(msg, from: bobPeerID)
} }
// Simulate desynchronization - Alice sends messages that Bob doesn't receive // Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 { for i in 0..<3 {
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: TestConstants.testPeerID2) _ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
} }
// With nonce carried in packet, decryption should not throw here // With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: TestConstants.testPeerID2) let desyncMessage = try aliceManager.encrypt("This now succeeds".data(using: .utf8)!, for: alicePeerID)
XCTAssertNoThrow(try bobManager.decrypt(desyncMessage, from: TestConstants.testPeerID1)) #expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID)
}
// Bob clears session and initiates new handshake // Bob clears session and initiates new handshake
bobManager.removeSession(for: TestConstants.testPeerID1) bobManager.removeSession(for: bobPeerID)
let rehandshake1 = try bobManager.initiateHandshake(with: TestConstants.testPeerID1) let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
// Alice should accept despite having a "valid" (but desynced) session // Alice should accept despite having a "valid" (but desynced) session
let rehandshake2 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake1) let rehandshake2 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake1)
XCTAssertNotNil(rehandshake2, "Alice should accept handshake to fix desync") #expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
// Complete handshake // Complete handshake
let rehandshake3 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: rehandshake2!) let rehandshake3 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: rehandshake2!)
XCTAssertNotNil(rehandshake3) #expect(rehandshake3 != nil)
_ = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: rehandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again // Verify communication works again
let testResynced = "Resynced".data(using: .utf8)! let testResynced = "Resynced".data(using: .utf8)!
let encryptedResync = try aliceManager.encrypt(testResynced, for: TestConstants.testPeerID2) let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: TestConstants.testPeerID1) let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
XCTAssertEqual(decryptedResync, testResynced) #expect(decryptedResync == testResynced)
}
// MARK: - Performance Tests
func testHandshakePerformance() throws {
measure {
do {
let alice = NoiseSession(peerID: "bob", role: .initiator, keychain: mockKeychain, localStaticKey: aliceKey)
let bob = NoiseSession(peerID: "alice", role: .responder, keychain: mockKeychain, localStaticKey: bobKey)
try performHandshake(initiator: alice, responder: bob)
} catch {
XCTFail("Handshake failed: \(error)")
}
}
}
func testEncryptionPerformance() throws {
try establishSessions()
let message = TestHelpers.generateRandomData(length: 1024)
measure {
do {
for _ in 0..<100 {
let ciphertext = try aliceSession.encrypt(message)
_ = try bobSession.decrypt(ciphertext)
}
} catch {
XCTFail("Encryption/decryption failed: \(error)")
}
}
} }
// MARK: - Helper Methods // MARK: - Helper Methods
private func establishSessions() throws {
aliceSession = NoiseSession(
peerID: TestConstants.testPeerID2,
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
bobSession = NoiseSession(
peerID: TestConstants.testPeerID1,
role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey
)
try performHandshake(initiator: aliceSession, responder: bobSession)
}
private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws { private func performHandshake(initiator: NoiseSession, responder: NoiseSession) throws {
let msg1 = try initiator.startHandshake() let msg1 = try initiator.startHandshake()
let msg2 = try responder.processHandshakeMessage(msg1)! let msg2 = try responder.processHandshakeMessage(msg1)!
@@ -559,9 +524,9 @@ final class NoiseProtocolTests: XCTestCase {
} }
private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws { private func establishManagerSessions(aliceManager: NoiseSessionManager, bobManager: NoiseSessionManager) throws {
let msg1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2) let msg1 = try aliceManager.initiateHandshake(with: alicePeerID)
let msg2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg1)! let msg2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)!
let msg3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: msg2)! let msg3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: msg2)!
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: msg3) _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg3)
} }
} }
+60 -61
View File
@@ -5,20 +5,20 @@
// Tests for NIP-17 gift-wrapped private messages // Tests for NIP-17 gift-wrapped private messages
// //
import XCTest import Testing
import CryptoKit
import Foundation
@testable import bitchat @testable import bitchat
final class NostrProtocolTests: XCTestCase { struct NostrProtocolTests {
func testNIP17MessageRoundTrip() throws { @Test func nip17MessageRoundTrip() throws {
// Create sender and recipient identities // Create sender and recipient identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
#if DEBUG
print("Sender pubkey: \(sender.publicKeyHex)") print("Sender pubkey: \(sender.publicKeyHex)")
print("Recipient pubkey: \(recipient.publicKeyHex)") print("Recipient pubkey: \(recipient.publicKeyHex)")
#endif
// Create a test message // Create a test message
let originalContent = "Hello from NIP-17 test!" let originalContent = "Hello from NIP-17 test!"
@@ -30,10 +30,8 @@ final class NostrProtocolTests: XCTestCase {
senderIdentity: sender senderIdentity: sender
) )
#if DEBUG
print("Gift wrap created with ID: \(giftWrap.id)") print("Gift wrap created with ID: \(giftWrap.id)")
print("Gift wrap pubkey: \(giftWrap.pubkey)") print("Gift wrap pubkey: \(giftWrap.pubkey)")
#endif
// Decrypt the gift wrap // Decrypt the gift wrap
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage( let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
@@ -42,20 +40,18 @@ final class NostrProtocolTests: XCTestCase {
) )
// Verify // Verify
XCTAssertEqual(decryptedContent, originalContent) #expect(decryptedContent == originalContent)
XCTAssertEqual(senderPubkey, sender.publicKeyHex) #expect(senderPubkey == sender.publicKeyHex)
// Verify timestamp is reasonable (within last minute) // Verify timestamp is reasonable (within last minute)
let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp)) let messageDate = Date(timeIntervalSince1970: TimeInterval(timestamp))
let timeDiff = abs(messageDate.timeIntervalSinceNow) let timeDiff = abs(messageDate.timeIntervalSinceNow)
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent") #expect(timeDiff < 60, "Message timestamp should be recent")
#if DEBUG
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)") print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
#endif
} }
func testGiftWrapUsesUniqueEphemeralKeys() throws { @Test func giftWrapUsesUniqueEphemeralKeys() throws {
// Create identities // Create identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
@@ -74,11 +70,10 @@ final class NostrProtocolTests: XCTestCase {
) )
// Gift wrap pubkeys should be different (unique ephemeral keys) // Gift wrap pubkeys should be different (unique ephemeral keys)
XCTAssertNotEqual(message1.pubkey, message2.pubkey) #expect(message1.pubkey != message2.pubkey)
#if DEBUG
print("Message 1 gift wrap pubkey: \(message1.pubkey)") print("Message 1 gift wrap pubkey: \(message1.pubkey)")
print("Message 2 gift wrap pubkey: \(message2.pubkey)") print("Message 2 gift wrap pubkey: \(message2.pubkey)")
#endif
// Both should decrypt successfully // Both should decrypt successfully
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage( let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
@@ -90,11 +85,11 @@ final class NostrProtocolTests: XCTestCase {
recipientIdentity: recipient recipientIdentity: recipient
) )
XCTAssertEqual(content1, "Message 1") #expect(content1 == "Message 1")
XCTAssertEqual(content2, "Message 2") #expect(content2 == "Message 2")
} }
func testDecryptionFailsWithWrongRecipient() throws { @Test func decryptionFailsWithWrongRecipient() throws {
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let wrongRecipient = try NostrIdentity.generate() let wrongRecipient = try NostrIdentity.generate()
@@ -107,13 +102,20 @@ final class NostrProtocolTests: XCTestCase {
) )
// Try to decrypt with wrong recipient // Try to decrypt with wrong recipient
XCTAssertThrowsError(try NostrProtocol.decryptPrivateMessage( if #available(macOS 14.4, iOS 17.4, *) {
giftWrap: giftWrap, #expect(throws: CryptoKitError.authenticationFailure) {
recipientIdentity: wrongRecipient try NostrProtocol.decryptPrivateMessage(
)) { error in giftWrap: giftWrap,
#if DEBUG recipientIdentity: wrongRecipient
print("Expected error when decrypting with wrong key: \(error)") )
#endif }
} else {
#expect(throws: (any Error).self) {
try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)
}
} }
} }
@@ -125,10 +127,11 @@ final class NostrProtocolTests: XCTestCase {
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID) // Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
let messageID = "TEST-MSG-DELIVERED-1" let messageID = "TEST-MSG-DELIVERED-1"
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed delivered ack") let embedded = try #require(
return NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID),
} "Failed to embed delivered ack"
)
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally) // Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
let giftWrap = try NostrProtocol.createPrivateMessage( let giftWrap = try NostrProtocol.createPrivateMessage(
@@ -138,7 +141,7 @@ final class NostrProtocolTests: XCTestCase {
) )
// Ensure v2 format was used for ciphertext // Ensure v2 format was used for ciphertext
XCTAssertTrue(giftWrap.content.hasPrefix("v2:")) #expect(giftWrap.content.hasPrefix("v2:"))
// Decrypt as recipient // Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
@@ -147,39 +150,37 @@ final class NostrProtocolTests: XCTestCase {
) )
// Verify sender is correct // Verify sender is correct
XCTAssertEqual(senderPubkey, sender.publicKeyHex) #expect(senderPubkey == sender.publicKeyHex)
// Parse BitChat payload // Parse BitChat payload
XCTAssertTrue(content.hasPrefix("bitchat1:")) #expect(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count)) let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url), let packetData = try #require(Self.base64URLDecode(base64url))
let packet = BitchatPacket.from(packetData) else { let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
return XCTFail("Failed to decode bitchat packet")
} #expect(packet.type == MessageType.noiseEncrypted.rawValue)
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue) let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type { switch payload.type {
case .delivered: case .delivered:
let mid = String(data: payload.data, encoding: .utf8) let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID) #expect(mid == messageID)
default: default:
XCTFail("Unexpected payload type: \(payload.type)") Issue.record("Unexpected payload type: \(payload.type)")
} }
} }
func testAckRoundTripNIP44V2_ReadReceipt() throws { @Test func ackRoundTripNIP44V2_ReadReceipt() throws {
// Identities // Identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let messageID = "TEST-MSG-READ-1" let messageID = "TEST-MSG-READ-1"
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { let embedded = try #require(
XCTFail("Failed to embed read ack") NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID),
return "Failed to embed read ack"
} )
let giftWrap = try NostrProtocol.createPrivateMessage( let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded, content: embedded,
@@ -187,30 +188,28 @@ final class NostrProtocolTests: XCTestCase {
senderIdentity: sender senderIdentity: sender
) )
XCTAssertTrue(giftWrap.content.hasPrefix("v2:")) #expect(giftWrap.content.hasPrefix("v2:"))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap, giftWrap: giftWrap,
recipientIdentity: recipient recipientIdentity: recipient
) )
XCTAssertEqual(senderPubkey, sender.publicKeyHex) #expect(senderPubkey == sender.publicKeyHex)
XCTAssertTrue(content.hasPrefix("bitchat1:")) #expect(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count)) let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url), let packetData = try #require(Self.base64URLDecode(base64url))
let packet = BitchatPacket.from(packetData) else { let packet = try #require(BitchatPacket.from(packetData), "Failed to decode bitchat packet")
return XCTFail("Failed to decode bitchat packet")
} #expect(packet.type == MessageType.noiseEncrypted.rawValue)
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue) let payload = try #require(NoisePayload.decode(packet.payload), "Failed to decode NoisePayload")
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type { switch payload.type {
case .readReceipt: case .readReceipt:
let mid = String(data: payload.data, encoding: .utf8) let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID) #expect(mid == messageID)
default: default:
XCTFail("Unexpected payload type: \(payload.type)") Issue.record("Unexpected payload type: \(payload.type)")
} }
} }
@@ -1,7 +1,8 @@
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class NotificationStreamAssemblerTests: XCTestCase { struct NotificationStreamAssemblerTests {
private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket { private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket {
let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77]) let sender = Data([0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77])
return BitchatPacket( return BitchatPacket(
@@ -15,60 +16,51 @@ final class NotificationStreamAssemblerTests: XCTestCase {
) )
} }
func testAssemblesSingleFrameAcrossChunks() { @Test func assemblesSingleFrameAcrossChunks() throws {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet = makePacket() let packet = makePacket()
guard let frame = packet.toBinaryData(padding: false) else { let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
return XCTFail("Failed to encode packet")
} #expect(BinaryProtocol.decode(frame) != nil)
XCTAssertNotNil(BinaryProtocol.decode(frame))
let payloadLen = (Int(frame[12]) << 8) | Int(frame[13]) let payloadLen = (Int(frame[12]) << 8) | Int(frame[13])
XCTAssertEqual(payloadLen, packet.payload.count) #expect(payloadLen == packet.payload.count)
let splitIndex = min(20, max(1, frame.count / 2)) let splitIndex = min(20, max(1, frame.count / 2))
let first = frame.prefix(splitIndex) let first = frame.prefix(splitIndex)
let second = frame.suffix(from: splitIndex) let second = frame.suffix(from: splitIndex)
XCTAssertEqual(first.count + second.count, frame.count) #expect(first.count + second.count == frame.count)
var result = assembler.append(first) var result = assembler.append(first)
XCTAssertTrue(result.frames.isEmpty) #expect(result.frames.isEmpty)
XCTAssertTrue(result.droppedPrefixes.isEmpty) #expect(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset) #expect(!result.reset)
result = assembler.append(second) result = assembler.append(second)
XCTAssertEqual(result.frames.count, 1) #expect(result.frames.count == 1)
XCTAssertTrue(result.droppedPrefixes.isEmpty) #expect(result.droppedPrefixes.isEmpty)
XCTAssertFalse(result.reset) #expect(!result.reset)
guard let frameData = result.frames.first else { let frameData = try #require(result.frames.first, "Missing frame data")
return XCTFail("Missing frame data") #expect(frameData.count == frame.count)
}
if frameData.count != frame.count { let decoded = try #require(BinaryProtocol.decode(frameData), "Failed to decode frame")
XCTFail("Frame size mismatch: expected \(frame.count) got \(frameData.count)\nframe=\(Array(frame))\nassembled=\(Array(frameData))") #expect(decoded.type == packet.type)
return #expect(decoded.payload == packet.payload)
} #expect(decoded.senderID == packet.senderID)
guard let decoded = BinaryProtocol.decode(frameData) else { #expect(decoded.timestamp == packet.timestamp)
return XCTFail("Failed to decode frame")
}
XCTAssertEqual(decoded.type, packet.type)
XCTAssertEqual(decoded.payload, packet.payload)
XCTAssertEqual(decoded.senderID, packet.senderID)
XCTAssertEqual(decoded.timestamp, packet.timestamp)
var directAssembler = NotificationStreamAssembler() var directAssembler = NotificationStreamAssembler()
let directResult = directAssembler.append(frame) let directResult = directAssembler.append(frame)
XCTAssertEqual(directResult.frames.first?.count, frame.count) #expect(directResult.frames.first?.count == frame.count)
} }
func testAssemblesMultipleFramesSequentially() { @Test func assemblesMultipleFramesSequentially() throws {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet1 = makePacket(timestamp: 0xABC) let packet1 = makePacket(timestamp: 0xABC)
let packet2 = makePacket(timestamp: 0xDEF) let packet2 = makePacket(timestamp: 0xDEF)
guard let frame1 = packet1.toBinaryData(padding: false), let frame1 = try #require(packet1.toBinaryData(padding: false), "Failed to encode packet")
let frame2 = packet2.toBinaryData(padding: false) else { let frame2 = try #require(packet2.toBinaryData(padding: false), "Failed to encode packet")
return XCTFail("Failed to encode packets")
}
var combined = Data() var combined = Data()
combined.append(frame1) combined.append(frame1)
@@ -77,35 +69,30 @@ final class NotificationStreamAssemblerTests: XCTestCase {
let secondChunk = combined.suffix(from: 20) let secondChunk = combined.suffix(from: 20)
var result = assembler.append(firstChunk) var result = assembler.append(firstChunk)
XCTAssertTrue(result.frames.isEmpty) #expect(result.frames.isEmpty)
result = assembler.append(secondChunk) result = assembler.append(secondChunk)
XCTAssertEqual(result.frames.count, 2) #expect(result.frames.count == 2)
guard let decoded1 = BinaryProtocol.decode(result.frames[0]),
let decoded2 = BinaryProtocol.decode(result.frames[1]) else { let decoded1 = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame")
return XCTFail("Failed to decode frames") let decoded2 = try #require(BinaryProtocol.decode(result.frames[1]), "Failed to decode frame")
} #expect(decoded1.timestamp == packet1.timestamp)
XCTAssertEqual(decoded1.timestamp, packet1.timestamp) #expect(decoded2.timestamp == packet2.timestamp)
XCTAssertEqual(decoded2.timestamp, packet2.timestamp)
} }
func testDropsInvalidPrefixByte() { @Test func dropsInvalidPrefixByte() throws {
var assembler = NotificationStreamAssembler() var assembler = NotificationStreamAssembler()
let packet = makePacket(timestamp: 0xF00) let packet = makePacket(timestamp: 0xF00)
guard let frame = packet.toBinaryData(padding: false) else { let frame = try #require(packet.toBinaryData(padding: false), "Failed to encode packet")
return XCTFail("Failed to encode packet")
}
var noisyFrame = Data([0x00]) var noisyFrame = Data([0x00])
noisyFrame.append(frame) noisyFrame.append(frame)
let result = assembler.append(noisyFrame) let result = assembler.append(noisyFrame)
XCTAssertEqual(result.droppedPrefixes, [0x00]) #expect(result.droppedPrefixes == [0x00])
XCTAssertEqual(result.frames.count, 1) #expect(result.frames.count == 1)
XCTAssertFalse(result.reset) #expect(result.reset == false)
guard let decoded = BinaryProtocol.decode(result.frames[0]) else { let decoded = try #require(BinaryProtocol.decode(result.frames[0]), "Failed to decode frame after drop")
return XCTFail("Failed to decode frame after drop") #expect(decoded.timestamp == packet.timestamp)
}
XCTAssertEqual(decoded.timestamp, packet.timestamp)
} }
} }
@@ -5,30 +5,29 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import XCTest import Testing
@testable import bitchat @testable import bitchat
final class BinaryProtocolPaddingTests: XCTestCase { struct BinaryProtocolPaddingTests {
func test_padded_vs_unpadded_length() throws { @Test func padded_vs_unpadded_length() throws {
// Use helper to create a small test packet // Use helper to create a small test packet
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") } let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") } let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
XCTAssertGreaterThanOrEqual(padded.count, unpadded.count, "Padded frame should be >= unpadded") #expect(padded.count >= unpadded.count, "Padded frame should be >= unpadded")
} }
func test_decode_padded_and_unpadded_round_trip() throws { @Test func decode_padded_and_unpadded_round_trip() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
// Padded
guard let padded = BinaryProtocol.encode(packet, padding: true) else { return XCTFail("encode padded") } let padded = try #require(BinaryProtocol.encode(packet, padding: true), "encode padded")
guard let dec1 = BinaryProtocol.decode(padded) else { return XCTFail("decode padded") } let dec1 = try #require(BinaryProtocol.decode(padded), "decode padded")
XCTAssertEqual(dec1.type, packet.type) #expect(dec1.type == packet.type)
XCTAssertEqual(dec1.payload, packet.payload) #expect(dec1.payload == packet.payload)
// Unpadded
guard let unpadded = BinaryProtocol.encode(packet, padding: false) else { return XCTFail("encode unpadded") } let unpadded = try #require(BinaryProtocol.encode(packet, padding: false), "encode unpadded")
guard let dec2 = BinaryProtocol.decode(unpadded) else { return XCTFail("decode unpadded") } let dec2 = try #require(BinaryProtocol.decode(unpadded), "decode unpadded")
XCTAssertEqual(dec2.type, packet.type) #expect(dec2.type == packet.type)
XCTAssertEqual(dec2.payload, packet.payload) #expect(dec2.payload == packet.payload)
} }
} }
+120 -209
View File
@@ -6,119 +6,89 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class BinaryProtocolTests: XCTestCase { struct BinaryProtocolTests {
// MARK: - Basic Encoding/Decoding Tests // MARK: - Basic Encoding/Decoding Tests
func testBasicPacketEncodingDecoding() throws { @Test func basicPacketEncodingDecoding() throws {
let originalPacket = TestHelpers.createTestPacket() let originalPacket = TestHelpers.createTestPacket()
// Encode let encodedData = try #require(BinaryProtocol.encode(originalPacket), "Failed to encode packet")
guard let encodedData = BinaryProtocol.encode(originalPacket) else { let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet")
XCTFail("Failed to encode packet")
return
}
// Decode
guard let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to decode packet")
return
}
// Verify // Verify
XCTAssertEqual(decodedPacket.type, originalPacket.type) #expect(decodedPacket.type == originalPacket.type)
XCTAssertEqual(decodedPacket.ttl, originalPacket.ttl) #expect(decodedPacket.ttl == originalPacket.ttl)
XCTAssertEqual(decodedPacket.timestamp, originalPacket.timestamp) #expect(decodedPacket.timestamp == originalPacket.timestamp)
XCTAssertEqual(decodedPacket.payload, originalPacket.payload) #expect(decodedPacket.payload == originalPacket.payload)
// Sender ID should match (accounting for padding) // Sender ID should match (accounting for padding)
let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize) let originalSenderID = originalPacket.senderID.prefix(BinaryProtocol.senderIDSize)
let decodedSenderID = decodedPacket.senderID.trimmingNullBytes() let decodedSenderID = decodedPacket.senderID.trimmingNullBytes()
XCTAssertEqual(decodedSenderID, originalSenderID) #expect(decodedSenderID == originalSenderID)
} }
func testPacketWithRecipient() throws { @Test func packetWithRecipient() throws {
let recipientID = TestConstants.testPeerID2 let recipientID = PeerID(str: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789")
let packet = TestHelpers.createTestPacket(recipientID: recipientID) let packet = TestHelpers.createTestPacket(recipientID: recipientID)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with recipient")
// Encode and decode let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with recipient")
guard let encodedData = BinaryProtocol.encode(packet),
let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to encode/decode packet with recipient")
return
}
// Verify recipient // Verify recipient
XCTAssertNotNil(decodedPacket.recipientID) #expect(decodedPacket.recipientID != nil)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
XCTAssertTrue(String(data: decodedRecipientID!, encoding: .utf8) == recipientID) // TODO: Check if this is intended that the decoding only gets the first 8
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
} }
func testPacketWithSignature() throws { @Test func packetWithSignature() throws {
let packet = TestHelpers.createTestPacket( let packet = TestHelpers.createTestPacket(signature: TestConstants.testSignature)
signature: TestConstants.testSignature let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with signature")
) let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode packet with signature")
// Encode and decode
guard let encodedData = BinaryProtocol.encode(packet),
let decodedPacket = BinaryProtocol.decode(encodedData) else {
XCTFail("Failed to encode/decode packet with signature")
return
}
// Verify signature // Verify signature
XCTAssertNotNil(decodedPacket.signature) #expect(decodedPacket.signature != nil)
XCTAssertEqual(decodedPacket.signature, TestConstants.testSignature) #expect(decodedPacket.signature == TestConstants.testSignature)
} }
// MARK: - Compression Tests // MARK: - Compression Tests
func testPayloadCompression() throws { @Test("Create a large, compressible payload above current threshold (2048B)")
// Create a large, compressible payload above current threshold (2048B) func payloadCompression() throws {
let repeatedString = String(repeating: "This is a test message. ", count: 200) let repeatedString = String(repeating: "This is a test message. ", count: 200)
let largePayload = repeatedString.data(using: .utf8)! let largePayload = repeatedString.data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: largePayload) let packet = TestHelpers.createTestPacket(payload: largePayload)
// Encode (should compress) // Encode (should compress)
guard let encodedData = BinaryProtocol.encode(packet) else { let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with large payload")
XCTFail("Failed to encode packet with large payload")
return
}
// The encoded size should be smaller than uncompressed due to compression // The encoded size should be smaller than uncompressed due to compression
let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count let uncompressedSize = BinaryProtocol.headerSize + BinaryProtocol.senderIDSize + largePayload.count
XCTAssertLessThan(encodedData.count, uncompressedSize) #expect(encodedData.count < uncompressedSize)
// Decode and verify // Decode and verify
guard let decodedPacket = BinaryProtocol.decode(encodedData) else { let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode compressed packet")
XCTFail("Failed to decode compressed packet")
return
}
XCTAssertEqual(decodedPacket.payload, largePayload) #expect(decodedPacket.payload == largePayload)
} }
func testSmallPayloadNoCompression() throws { @Test("Small payloads should not be compressed")
// Small payloads should not be compressed func smallPayloadNoCompression() throws {
let smallPayload = "Hi".data(using: .utf8)! let smallPayload = "Hi".data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: smallPayload) let packet = TestHelpers.createTestPacket(payload: smallPayload)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
guard let encodedData = BinaryProtocol.encode(packet), let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
let decodedPacket = BinaryProtocol.decode(encodedData) else { #expect(decodedPacket.payload == smallPayload)
XCTFail("Failed to encode/decode small packet")
return
}
XCTAssertEqual(decodedPacket.payload, smallPayload)
} }
// MARK: - Message Padding Tests // MARK: - Message Padding Tests
func testMessagePadding() throws { @Test func messagePadding() throws {
let payloads = [ let payloads = [
"Short", "Short",
String(repeating: "Medium length message content ", count: 10), // ~300 bytes String(repeating: "Medium length message content ", count: 10), // ~300 bytes
@@ -130,43 +100,32 @@ final class BinaryProtocolTests: XCTestCase {
for payload in payloads { for payload in payloads {
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!) let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
guard let encodedData = BinaryProtocol.encode(packet) else {
XCTFail("Failed to encode packet")
continue
}
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently) // Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
let blockSizes = [256, 512, 1024, 2048] let blockSizes = [256, 512, 1024, 2048]
if encodedData.count <= 2048 { if encodedData.count <= 2048 {
XCTAssertTrue(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size") #expect(blockSizes.contains(encodedData.count), "Encoded size \(encodedData.count) is not a standard block size")
} else { } else {
// For very large payloads we expect no additional padding beyond raw size // For very large payloads we expect no additional padding beyond raw size
XCTAssertGreaterThan(encodedData.count, 2048) #expect(encodedData.count > 2048)
} }
encodedSizes.insert(encodedData.count) encodedSizes.insert(encodedData.count)
// Verify decoding works // Verify decoding works
guard let decodedPacket = BinaryProtocol.decode(encodedData) else { let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode padded packet")
XCTFail("Failed to decode padded packet") #expect(String(data: decodedPacket.payload, encoding: .utf8) == payload)
continue
}
XCTAssertEqual(String(data: decodedPacket.payload, encoding: .utf8), payload)
} }
// Different payload sizes (within <=2048) may map to the same bucket depending on compression. // Different payload sizes (within <=2048) may map to the same bucket depending on compression.
// Require at least one padded size to be present. // Require at least one padded size to be present.
XCTAssertGreaterThanOrEqual(encodedSizes.filter { $0 <= 2048 }.count, 1, "Expected at least one padded size up to 2048, got \(encodedSizes)") #expect(encodedSizes.filter { $0 <= 2048 }.count >= 1, "Expected at least one padded size up to 2048, got \(encodedSizes)")
} }
func testInvalidPKCS7PaddingIsRejected() throws { @Test func invalidPKCS7PaddingIsRejected() throws {
let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small let pkt = TestHelpers.createTestPacket(payload: Data(repeating: 0x41, count: 50)) // small
guard let enc0 = BinaryProtocol.encode(pkt) else { let enc0 = try #require(BinaryProtocol.encode(pkt), "encode failed")
XCTFail("encode failed")
return
}
// Force padding to known block for test stability // Force padding to known block for test stability
var enc = MessagePadding.pad(enc0, toSize: 256) var enc = MessagePadding.pad(enc0, toSize: 256)
let unpadded = MessagePadding.unpad(enc) let unpadded = MessagePadding.unpad(enc)
@@ -177,39 +136,33 @@ final class BinaryProtocolTests: XCTestCase {
let maybe = BinaryProtocol.decode(enc) let maybe = BinaryProtocol.decode(enc)
// If decode still succeeds (nested pad edge case), at least ensure payload integrity // If decode still succeeds (nested pad edge case), at least ensure payload integrity
if let pkt2 = maybe { if let pkt2 = maybe {
XCTAssertEqual(pkt2.payload, pkt.payload) #expect(pkt2.payload == pkt.payload)
} else { } else {
XCTAssertNil(maybe) #expect(maybe == nil)
} }
} else { } else {
// If no padding was applied, just assert decode succeeds (nothing to test) // If no padding was applied, just assert decode succeeds (nothing to test)
XCTAssertNotNil(BinaryProtocol.decode(enc)) #expect(BinaryProtocol.decode(enc) != nil)
} }
} }
// MARK: - Message Encoding/Decoding Tests // MARK: - Message Encoding/Decoding Tests
func testMessageEncodingDecoding() throws { @Test func messageEncodingDecoding() throws {
let message = TestHelpers.createTestMessage() let message = TestHelpers.createTestMessage()
guard let payload = message.toBinaryPayload() else { let payload = try #require(message.toBinaryPayload(), "Failed to encode message to binary")
XCTFail("Failed to encode message to binary")
return
}
guard let decodedMessage = BitchatMessage(payload) else { let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message from binary")
XCTFail("Failed to decode message from binary")
return
}
XCTAssertEqual(decodedMessage.content, message.content) #expect(decodedMessage.content == message.content)
XCTAssertEqual(decodedMessage.sender, message.sender) #expect(decodedMessage.sender == message.sender)
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID) #expect(decodedMessage.senderPeerID == message.senderPeerID)
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate) #expect(decodedMessage.isPrivate == message.isPrivate)
// Timestamp should be close (within 1 second due to conversion) // Timestamp should be close (within 1 second due to conversion)
let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp)) let timeDiff = abs(decodedMessage.timestamp.timeIntervalSince(message.timestamp))
XCTAssertLessThan(timeDiff, 1.0) #expect(timeDiff < 1)
} }
func testPrivateMessageEncoding() throws { func testPrivateMessageEncoding() throws {
@@ -218,30 +171,22 @@ final class BinaryProtocolTests: XCTestCase {
recipientNickname: TestConstants.testNickname2 recipientNickname: TestConstants.testNickname2
) )
guard let payload = message.toBinaryPayload(), let payload = try #require(message.toBinaryPayload(), "Failed to encode private message")
let decodedMessage = BitchatMessage(payload) else { let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode private message")
XCTFail("Failed to encode/decode private message")
return
}
XCTAssertTrue(decodedMessage.isPrivate) #expect(decodedMessage.isPrivate)
XCTAssertEqual(decodedMessage.recipientNickname, TestConstants.testNickname2) #expect(decodedMessage.recipientNickname == TestConstants.testNickname2)
} }
func testMessageWithMentions() throws { @Test func messageWithMentions() throws {
let mentions = [TestConstants.testNickname2, TestConstants.testNickname3] let mentions = [TestConstants.testNickname2, TestConstants.testNickname3]
let message = TestHelpers.createTestMessage(mentions: mentions) let message = TestHelpers.createTestMessage(mentions: mentions)
let payload = try #require(message.toBinaryPayload(), "Failed to encode message with mentions")
guard let payload = message.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode message with mentions")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.mentions == mentions)
XCTFail("Failed to encode/decode message with mentions")
return
}
XCTAssertEqual(decodedMessage.mentions, mentions)
} }
func testRelayMessageEncoding() throws { @Test func relayMessageEncoding() throws {
let message = BitchatMessage( let message = BitchatMessage(
id: UUID().uuidString, id: UUID().uuidString,
sender: TestConstants.testNickname1, sender: TestConstants.testNickname1,
@@ -251,105 +196,77 @@ final class BinaryProtocolTests: XCTestCase {
originalSender: TestConstants.testNickname3, originalSender: TestConstants.testNickname3,
isPrivate: false, isPrivate: false,
recipientNickname: nil, recipientNickname: nil,
senderPeerID: TestConstants.testPeerID1,
mentions: nil mentions: nil
) )
let payload = try #require(message.toBinaryPayload(), "Failed to encode relay message")
guard let payload = message.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to decode relay message")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.isRelay)
XCTFail("Failed to encode/decode relay message") #expect(decodedMessage.originalSender == TestConstants.testNickname3)
return
}
XCTAssertTrue(decodedMessage.isRelay)
XCTAssertEqual(decodedMessage.originalSender, TestConstants.testNickname3)
} }
// MARK: - Edge Cases and Error Handling // MARK: - Edge Cases and Error Handling
func testInvalidDataDecoding() { @Test("Too small data")
// Too small data func invalidDataDecoding() throws {
let tooSmall = Data(repeating: 0, count: 5) let tooSmall = Data(repeating: 0, count: 5)
XCTAssertNil(BinaryProtocol.decode(tooSmall)) #expect(BinaryProtocol.decode(tooSmall) == nil)
// Random data // Random data
let random = TestHelpers.generateRandomData(length: 100) let random = TestHelpers.generateRandomData(length: 100)
XCTAssertNil(BinaryProtocol.decode(random)) #expect(BinaryProtocol.decode(random) == nil)
// Corrupted header // Corrupted header
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard var encoded = BinaryProtocol.encode(packet) else { var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
XCTFail("Failed to encode test packet")
return
}
// Corrupt the version byte // Corrupt the version byte
encoded[0] = 0xFF encoded[0] = 0xFF
XCTAssertNil(BinaryProtocol.decode(encoded)) #expect(BinaryProtocol.decode(encoded) == nil)
} }
func testLargeMessageHandling() throws { @Test("Test maximum size handling")
// Test maximum size handling func largeMessageHandling() throws {
let largeContent = String(repeating: "X", count: 65535) // Max uint16 let largeContent = String(repeating: "X", count: 65535) // Max uint16
let message = TestHelpers.createTestMessage(content: largeContent) let message = TestHelpers.createTestMessage(content: largeContent)
let payload = try #require(message.toBinaryPayload(), "Failed to handle large message")
guard let payload = message.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle large message")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.content == largeContent)
XCTFail("Failed to handle large message")
return
}
XCTAssertEqual(decodedMessage.content, largeContent)
} }
func testEmptyFieldsHandling() throws { @Test("Test message with empty content")
// Test message with empty content func emptyFieldsHandling() throws {
let emptyMessage = TestHelpers.createTestMessage(content: "") let emptyMessage = TestHelpers.createTestMessage(content: "")
let payload = try #require(emptyMessage.toBinaryPayload(), "Failed to handle empty message")
guard let payload = emptyMessage.toBinaryPayload(), let decodedMessage = try #require(BitchatMessage(payload), "Failed to handle empty message")
let decodedMessage = BitchatMessage(payload) else { #expect(decodedMessage.content.isEmpty)
XCTFail("Failed to handle empty message")
return
}
XCTAssertEqual(decodedMessage.content, "")
} }
// MARK: - Protocol Version Tests // MARK: - Protocol Version Tests
func testProtocolVersionHandling() throws { @Test("Test with supported version (version is always 1 in init)")
// Test with supported version (version is always 1 in init) func protocolVersionHandling() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
let encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet with version")
guard let encoded = BinaryProtocol.encode(packet), let decoded = try #require(BinaryProtocol.decode(encoded), "Failed to decode packet with version")
let decoded = BinaryProtocol.decode(encoded) else { #expect(decoded.version == 1)
XCTFail("Failed to encode/decode packet with version")
return
}
XCTAssertEqual(decoded.version, 1)
} }
func testUnsupportedProtocolVersion() throws { @Test("Create packet data with unsupported version")
// Create packet data with unsupported version func unsupportedProtocolVersion() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
var encoded = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
guard var encoded = BinaryProtocol.encode(packet) else {
XCTFail("Failed to encode packet")
return
}
// Manually change version byte to unsupported value // Manually change version byte to unsupported value
encoded[0] = 99 // Unsupported version encoded[0] = 99 // Unsupported version
// Should fail to decode // Should fail to decode
XCTAssertNil(BinaryProtocol.decode(encoded)) #expect(BinaryProtocol.decode(encoded) == nil)
} }
// MARK: - Bounds Checking Tests (Crash Prevention) // MARK: - Bounds Checking Tests (Crash Prevention)
func testMalformedPacketWithInvalidPayloadLength() throws { @Test("Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available")
// Test the specific crash scenario: payloadLength = 193 (0xc1) but only 30 bytes available func malformedPacketWithInvalidPayloadLength() throws {
var malformedData = Data() var malformedData = Data()
// Valid header (13 bytes) // Valid header (13 bytes)
@@ -379,20 +296,17 @@ final class BinaryProtocolTests: XCTestCase {
} }
// Total data is now 30 bytes, but payloadLength claims 193 // Total data is now 30 bytes, but payloadLength claims 193
XCTAssertEqual(malformedData.count, 30) #expect(malformedData.count == 30)
// This should not crash - should return nil gracefully // This should not crash - should return nil gracefully
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Malformed packet with invalid payload length should return nil, not crash") #expect(result == nil, "Malformed packet with invalid payload length should return nil, not crash")
} }
func testTruncatedPacketHandling() throws { @Test("Test various truncation scenarios")
// Test various truncation scenarios func truncatedPacketHandling() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard let validEncoded = BinaryProtocol.encode(packet) else { let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
XCTFail("Failed to encode test packet")
return
}
// Test truncation at various points // Test truncation at various points
let truncationPoints = [0, 5, 10, 15, 20, 25] let truncationPoints = [0, 5, 10, 15, 20, 25]
@@ -400,12 +314,12 @@ final class BinaryProtocolTests: XCTestCase {
for point in truncationPoints { for point in truncationPoints {
let truncated = validEncoded.prefix(point) let truncated = validEncoded.prefix(point)
let result = BinaryProtocol.decode(truncated) let result = BinaryProtocol.decode(truncated)
XCTAssertNil(result, "Truncated packet at \(point) bytes should return nil, not crash") #expect(result == nil, "Truncated packet at \(point) bytes should return nil, not crash")
} }
} }
func testMalformedCompressedPacket() throws { @Test("Test compressed packet with invalid original size")
// Test compressed packet with invalid original size func malformedCompressedPacket() throws {
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -434,11 +348,11 @@ final class BinaryProtocolTests: XCTestCase {
// Should handle this gracefully // Should handle this gracefully
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Malformed compressed packet should return nil, not crash") #expect(result == nil, "Malformed compressed packet should return nil, not crash")
} }
func testExcessivelyLargePayloadLength() throws { @Test("Test packet claiming extremely large payload")
// Test packet claiming extremely large payload func excessivelyLargePayloadLength() throws {
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -467,11 +381,11 @@ final class BinaryProtocolTests: XCTestCase {
// Should handle this gracefully without trying to allocate massive amounts of memory // Should handle this gracefully without trying to allocate massive amounts of memory
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Packet with excessive payload length should return nil, not crash") #expect(result == nil, "Packet with excessive payload length should return nil, not crash")
} }
func testCompressedPacketWithInvalidOriginalSize() throws { @Test("Test compressed packet with unreasonable original size")
// Test compressed packet with unreasonable original size func compressedPacketWithInvalidOriginalSize() throws {
var malformedData = Data() var malformedData = Data()
// Valid header // Valid header
@@ -509,11 +423,11 @@ final class BinaryProtocolTests: XCTestCase {
} }
let result = BinaryProtocol.decode(malformedData) let result = BinaryProtocol.decode(malformedData)
XCTAssertNil(result, "Compressed packet with invalid original size should return nil, not crash") #expect(result == nil, "Compressed packet with invalid original size should return nil, not crash")
} }
func testMaliciousPacketWithIntegerOverflow() throws { @Test("Test packet designed to cause integer overflow")
// Test packet designed to cause integer overflow func maliciousPacketWithIntegerOverflow() throws {
var maliciousData = Data() var maliciousData = Data()
// Valid header // Valid header
@@ -548,27 +462,24 @@ final class BinaryProtocolTests: XCTestCase {
// Should handle gracefully without integer overflow issues // Should handle gracefully without integer overflow issues
let result = BinaryProtocol.decode(maliciousData) let result = BinaryProtocol.decode(maliciousData)
XCTAssertNil(result, "Malicious packet designed for integer overflow should return nil, not crash") #expect(result == nil, "Malicious packet designed for integer overflow should return nil, not crash")
} }
func testPartialHeaderData() throws { @Test("Test packets with incomplete headers")
// Test packets with incomplete headers func partialHeaderData() throws {
let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes let headerSizes = [0, 1, 5, 10, 12] // Various incomplete header sizes
for size in headerSizes { for size in headerSizes {
let partialData = Data(repeating: 0x01, count: size) let partialData = Data(repeating: 0x01, count: size)
let result = BinaryProtocol.decode(partialData) let result = BinaryProtocol.decode(partialData)
XCTAssertNil(result, "Partial header data (\(size) bytes) should return nil, not crash") #expect(result == nil, "Partial header data (\(size) bytes) should return nil, not crash")
} }
} }
func testBoundaryConditions() throws { @Test("Test exact boundary conditions")
// Test exact boundary conditions func boundaryConditions() throws {
let packet = TestHelpers.createTestPacket() let packet = TestHelpers.createTestPacket()
guard let validEncoded = BinaryProtocol.encode(packet) else { let validEncoded = try #require(BinaryProtocol.encode(packet), "Failed to encode test packet")
XCTFail("Failed to encode test packet")
return
}
// If truncation only removes padding, decode may still succeed. Compute unpadded size. // If truncation only removes padding, decode may still succeed. Compute unpadded size.
let unpadded = MessagePadding.unpad(validEncoded) let unpadded = MessagePadding.unpad(validEncoded)
@@ -576,7 +487,7 @@ final class BinaryProtocolTests: XCTestCase {
let cut = max(1, unpadded.count - 10) let cut = max(1, unpadded.count - 10)
let truncatedCore = unpadded.prefix(cut) let truncatedCore = unpadded.prefix(cut)
let result = BinaryProtocol.decode(truncatedCore) let result = BinaryProtocol.decode(truncatedCore)
XCTAssertNil(result, "Truncated core frame should return nil, not crash") #expect(result == nil, "Truncated core frame should return nil, not crash")
// Test minimum valid size - create a valid minimal packet // Test minimum valid size - create a valid minimal packet
var minData = Data() var minData = Data()
@@ -14,11 +14,6 @@ struct TestConstants {
static let shortTimeout: TimeInterval = 1.0 static let shortTimeout: TimeInterval = 1.0
static let longTimeout: TimeInterval = 10.0 static let longTimeout: TimeInterval = 10.0
static let testPeerID1: PeerID = "PEER1234"
static let testPeerID2: PeerID = "PEER5678"
static let testPeerID3: PeerID = "PEER9012"
static let testPeerID4: PeerID = "PEER3456"
static let testNickname1 = "Alice" static let testNickname1 = "Alice"
static let testNickname2 = "Bob" static let testNickname2 = "Bob"
static let testNickname3 = "Charlie" static let testNickname3 = "Charlie"
+6 -14
View File
@@ -30,7 +30,7 @@ final class TestHelpers {
static func createTestMessage( static func createTestMessage(
content: String = TestConstants.testMessage1, content: String = TestConstants.testMessage1,
sender: String = TestConstants.testNickname1, sender: String = TestConstants.testNickname1,
senderPeerID: PeerID = TestConstants.testPeerID1, senderPeerID: PeerID = PeerID(str: UUID().uuidString),
isPrivate: Bool = false, isPrivate: Bool = false,
recipientNickname: String? = nil, recipientNickname: String? = nil,
mentions: [String]? = nil mentions: [String]? = nil
@@ -51,7 +51,7 @@ final class TestHelpers {
static func createTestPacket( static func createTestPacket(
type: UInt8 = 0x01, type: UInt8 = 0x01,
senderID: PeerID = TestConstants.testPeerID1, senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil, recipientID: PeerID? = nil,
payload: Data = "test payload".data(using: .utf8)!, payload: Data = "test payload".data(using: .utf8)!,
signature: Data? = nil, signature: Data? = nil,
@@ -90,7 +90,7 @@ final class TestHelpers {
if Date().timeIntervalSince(start) > timeout { if Date().timeIntervalSince(start) > timeout {
throw TestError.timeout throw TestError.timeout
} }
try await Task.sleep(nanoseconds: 10_000_000) // 10ms try await sleep(0.01)
} }
} }
@@ -104,7 +104,7 @@ final class TestHelpers {
} }
group.addTask { group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) try await sleep(1)
throw TestError.timeout throw TestError.timeout
} }
@@ -121,14 +121,6 @@ enum TestError: Error {
case testFailure(String) case testFailure(String)
} }
// MARK: - PeerID String Helpers func sleep(_ seconds: TimeInterval) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
/// Raw String can be passed as PeerID
extension PeerID: @retroactive ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self.init(str: value)
}
} }
/// Interpolated String can be passed as PeerID
extension PeerID: @retroactive ExpressibleByStringInterpolation {}
+181 -190
View File
@@ -6,11 +6,11 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import XCTest import Testing
import Foundation
@testable import bitchat @testable import bitchat
final class PeerIDTests: XCTestCase { struct PeerIDTests {
private let hex16 = "0011223344556677" private let hex16 = "0011223344556677"
private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" private let hex64 = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
@@ -22,212 +22,205 @@ final class PeerIDTests: XCTestCase {
// MARK: - Empty prefix // MARK: - Empty prefix
func test_init_empty_prefix_with16() { @Test func empty_prefix_with16() {
let peerID = PeerID(str: hex16) let peerID = PeerID(str: hex16)
XCTAssertEqual(peerID.id, hex16) #expect(peerID.id == hex16)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
func test_init_empty_prefix_with64() { @Test func empty_prefix_with64() {
let peerID = PeerID(str: hex64) let peerID = PeerID(str: hex64)
XCTAssertEqual(peerID.id, hex64) #expect(peerID.id == hex64)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
// MARK: - Mesh prefix // MARK: - Mesh prefix
func test_init_mesh_prefix_with16() { @Test func mesh_prefix_with16() {
let str = "mesh:" + hex16 let str = "mesh:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .mesh) #expect(peerID.prefix == .mesh)
} }
func test_init_mesh_prefix_with64() { @Test func mesh_prefix_with64() {
let str = "mesh:" + hex64 let str = "mesh:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .mesh) #expect(peerID.prefix == .mesh)
} }
// MARK: - Name prefix // MARK: - Name prefix
func test_init_name_prefix() { @Test func name_prefix() {
let str = "name:some_name" let str = "name:some_name"
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, "some_name") #expect(peerID.bare == "some_name")
XCTAssertEqual(peerID.prefix, .name) #expect(peerID.prefix == .name)
} }
// MARK: - Noise prefix // MARK: - Noise prefix
func test_init_noise_prefix_with16() { @Test func noise_prefix_with16() {
let str = "noise:" + hex16 let str = "noise:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .noise) #expect(peerID.prefix == .noise)
} }
func test_init_noise_prefix_with64() { @Test func noise_prefix_with64() {
let str = "noise:" + hex64 let str = "noise:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .noise) #expect(peerID.prefix == .noise)
} }
// MARK: - GeoDM prefix // MARK: - GeoDM prefix
func test_init_geoDM_prefix_with16() { @Test func geoDM_prefix_with16() {
let str = "nostr_" + hex16 let str = "nostr_" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .geoDM) #expect(peerID.prefix == .geoDM)
} }
func test_init_geoDM_prefix_with64() { @Test func geoDM_prefix_with64() {
let str = "nostr_" + hex64 let str = "nostr_" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .geoDM) #expect(peerID.prefix == .geoDM)
} }
// MARK: - GeoChat prefix // MARK: - GeoChat prefix
func test_init_geoChat_prefix_with16() { @Test func geoChat_prefix_with16() {
let str = "nostr:" + hex16 let str = "nostr:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex16) #expect(peerID.bare == hex16)
XCTAssertEqual(peerID.prefix, .geoChat) #expect(peerID.prefix == .geoChat)
} }
func test_init_geoChat_prefix_with64() { @Test func geoChat_prefix_with64() {
let str = "nostr:" + hex64 let str = "nostr:" + hex64
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, hex64) #expect(peerID.bare == hex64)
XCTAssertEqual(peerID.prefix, .geoChat) #expect(peerID.prefix == .geoChat)
} }
// MARK: - Edge cases // MARK: - Edge cases
func test_init_with_unknown_prefix() { @Test func with_unknown_prefix() {
let str = "unknown:" + hex16 let str = "unknown:" + hex16
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
// Falls back to .empty // Falls back to .empty
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, str) #expect(peerID.bare == str)
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
func test_init_with_only_prefix_no_bare() { @Test func with_only_prefix_no_bare() {
let str = "mesh:" let str = "mesh:"
let peerID = PeerID(str: str) let peerID = PeerID(str: str)
XCTAssertEqual(peerID.id, str) #expect(peerID.id == str)
XCTAssertEqual(peerID.bare, "") #expect(peerID.bare == "")
XCTAssertEqual(peerID.prefix, .mesh) #expect(peerID.prefix == .mesh)
} }
// MARK: - init?(data:) // MARK: - init?(data:)
func test_init_data_valid_utf8() { @Test func data_valid_utf8() {
let peerID = PeerID(data: Data(hex16.utf8)) let peerID = PeerID(data: Data(hex16.utf8))
XCTAssertNotNil(peerID) #expect(peerID != nil)
XCTAssertEqual(peerID?.bare, hex16) #expect(peerID?.bare == hex16)
XCTAssertEqual(peerID?.prefix, .empty) #expect(peerID?.prefix == .empty)
} }
func test_init_data_invalid_utf8() { @Test func data_invalid_utf8() {
// Random invalid UTF8 // Random invalid UTF8
let bytes: [UInt8] = [0xFF, 0xFE, 0xFA] let bytes: [UInt8] = [0xFF, 0xFE, 0xFA]
let peerID = PeerID(data: Data(bytes)) let peerID = PeerID(data: Data(bytes))
XCTAssertNil(peerID) #expect(peerID == nil)
} }
// MARK: - init(str: Substring) // MARK: - init(str: Substring)
func test_init_substring() { @Test func substring() {
let substring = hex64.prefix(16) let substring = hex64.prefix(16)
let peerID = PeerID(str: substring) let peerID = PeerID(str: substring)
XCTAssertEqual(peerID.id, String(substring)) #expect(peerID.id == String(substring))
XCTAssertEqual(peerID.bare, String(substring)) #expect(peerID.bare == String(substring))
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
// MARK: - init(nostr_ pubKey:) // MARK: - init(nostr_ pubKey:)
func test_init_nostrUnderscore_pubKey() { @Test func nostrUnderscore_pubKey() {
let pubKey = hex64 let pubKey = hex64
let peerID = PeerID(nostr_: pubKey) let peerID = PeerID(nostr_: pubKey)
XCTAssertEqual(peerID.id, "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))") #expect(peerID.id == "nostr_\(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))")
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))) #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength)))
XCTAssertEqual(peerID.prefix, .geoDM) #expect(peerID.prefix == .geoDM)
} }
// MARK: - init(nostr pubKey:) // MARK: - init(nostr pubKey:)
func test_init_nostr_pubKey() { @Test func nostr_pubKey() {
let pubKey = hex64 let pubKey = hex64
let peerID = PeerID(nostr: pubKey) let peerID = PeerID(nostr: pubKey)
XCTAssertEqual(peerID.id, "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))") #expect(peerID.id == "nostr:\(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))")
XCTAssertEqual(peerID.bare, String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))) #expect(peerID.bare == String(pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength)))
XCTAssertEqual(peerID.prefix, .geoChat) #expect(peerID.prefix == .geoChat)
} }
// MARK: - init(publicKey:) // MARK: - init(publicKey:)
func test_init_publicKey_derivesFingerprint() { @Test func publicKey_derivesFingerprint() {
let publicKey = Data(hex64.utf8) let publicKey = Data(hex64.utf8)
let expected = publicKey.sha256Fingerprint().prefix(16) let expected = publicKey.sha256Fingerprint().prefix(16)
let peerID = PeerID(publicKey: publicKey) let peerID = PeerID(publicKey: publicKey)
XCTAssertEqual(peerID.bare, String(expected)) #expect(peerID.bare == String(expected))
XCTAssertEqual(peerID.prefix, .empty) #expect(peerID.prefix == .empty)
} }
// MARK: - toShort() // MARK: - toShort()
func test_toShort_whenNoiseKeyExists() { @Test func toShort_whenNoiseKeyExists() {
let peerID = PeerID(str: hex64) let peerID = PeerID(str: hex64)
let short = peerID.toShort() let short = peerID.toShort()
// `toShort()` should derive 16-hex peerID
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16) let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
#expect(short.bare == String(expected))
XCTAssertEqual(short.bare, String(expected)) #expect(short.prefix == .empty)
XCTAssertEqual(short.prefix, .empty)
} }
func test_toShort_whenNoiseKeyExists_withNoisePrefix() { @Test func toShort_whenNoiseKeyExists_withNoisePrefix() {
let peerID = PeerID(str: "noise:" + hex64) let peerID = PeerID(str: "noise:" + hex64)
let short = peerID.toShort() let short = peerID.toShort()
// `toShort()` should derive 16-hex peerID
let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16) let expected = Data(hexString: hex64)!.sha256Fingerprint().prefix(16)
#expect(short.bare == String(expected))
XCTAssertEqual(short.bare, String(expected)) #expect(short.prefix == .empty)
XCTAssertEqual(short.prefix, .empty) #expect(peerID.prefix == .noise)
XCTAssertEqual(peerID.prefix, .noise)
} }
func test_toShort_whenNoNoiseKey() { @Test func toShort_whenNoNoiseKey() {
let peerID = PeerID(str: "some_random_key") let peerID = PeerID(str: "some_random_key")
let short = peerID.toShort() let short = peerID.toShort()
XCTAssertEqual(short, peerID) // unchanged #expect(short == peerID)
} }
// MARK: - Codable // MARK: - Codable
func test_codable_emptyPrefix() throws { @Test func codable_emptyPrefix() throws {
struct Dummy: Codable, Equatable { struct Dummy: Codable, Equatable {
let name: String let name: String
let peerID: PeerID let peerID: PeerID
@@ -237,13 +230,13 @@ final class PeerIDTests: XCTestCase {
let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}" let jsonString = "{\"name\":\"some name\",\"peerID\":\"\(str)\"}"
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
XCTAssertEqual(decoded.peerID, PeerID(str: str)) #expect(decoded.peerID == PeerID(str: str))
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString) #expect(String(data: encoded, encoding: .utf8) == jsonString)
} }
func test_codable_withPrefix() throws { @Test func codable_withPrefix() throws {
struct Dummy: Codable, Equatable { struct Dummy: Codable, Equatable {
let peerID: PeerID let peerID: PeerID
} }
@@ -252,193 +245,191 @@ final class PeerIDTests: XCTestCase {
let jsonString = "{\"peerID\":\"\(str)\"}" let jsonString = "{\"peerID\":\"\(str)\"}"
let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8)) let decoded = try JSONDecoder().decode(Dummy.self, from: Data(jsonString.utf8))
XCTAssertEqual(decoded.peerID, PeerID(str: str)) #expect(decoded.peerID == PeerID(str: str))
XCTAssertEqual(decoded.peerID.bare, hex16) #expect(decoded.peerID.bare == hex16)
XCTAssertEqual(decoded.peerID.prefix, .geoDM) #expect(decoded.peerID.prefix == .geoDM)
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
XCTAssertEqual(String(data: encoded, encoding: .utf8), jsonString) #expect(String(data: encoded, encoding: .utf8) == jsonString)
} }
func test_codable_multiplePrefixes() throws { @Test func codable_multiplePrefixes() throws {
// Loop across all Prefix cases (except .empty since already tested) // Loop across all Prefix cases (except .empty since already tested)
for prefix in PeerID.Prefix.allCases where prefix != .empty { for prefix in PeerID.Prefix.allCases where prefix != .empty {
let bare = hex16 let bare = hex16
let str = prefix.rawValue + bare let str = prefix.rawValue + bare
let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8)) let decoded = try JSONDecoder().decode(PeerID.self, from: Data("\"\(str)\"".utf8))
XCTAssertEqual(decoded.prefix, prefix) #expect(decoded.prefix == prefix)
XCTAssertEqual(decoded.bare, bare) #expect(decoded.bare == bare)
let encoded = try encoder.encode(decoded) let encoded = try encoder.encode(decoded)
XCTAssertEqual(String(data: encoded, encoding: .utf8), "\"\(str)\"") #expect(String(data: encoded, encoding: .utf8) == "\"\(str)\"")
} }
} }
// MARK: - Comparable // MARK: - Comparable
func test_comparable_sorting_and_equality() { @Test func comparable_sorting_and_equality() {
let p1 = PeerID(str: "aaa") let p1 = PeerID(str: "aaa")
let p2 = PeerID(str: "bbb") let p2 = PeerID(str: "bbb")
let p3 = PeerID(str: "bbb") let p3 = PeerID(str: "bbb")
XCTAssertTrue(p1 < p2) #expect(p1 < p2)
XCTAssertFalse(p2 < p1) #expect(p2 >= p1)
XCTAssertEqual(p2, p3) #expect(p2 == p3)
let sorted = [p2, p1].sorted() let sorted = [p2, p1].sorted()
XCTAssertEqual(sorted, [p1, p2]) #expect(sorted == [p1, p2])
} }
func test_equality() { @Test func equality() {
let string = "aaa" let string = "aaa"
let peerID = PeerID(str: string) let peerID = PeerID(str: string)
let badString = "bbb" let badString = "bbb"
// PeerID == String // PeerID == String
XCTAssertTrue(peerID == string) #expect(peerID == string)
XCTAssertTrue(peerID == Optional(string)) #expect(peerID == Optional(string))
XCTAssertTrue(Optional(peerID) == string) #expect(Optional(peerID) == string)
XCTAssertTrue(Optional(peerID) == Optional(string)) #expect(Optional(peerID) == Optional(string))
// PeerID != String // PeerID != String
XCTAssertTrue(peerID != badString) #expect(peerID != badString)
XCTAssertTrue(peerID != Optional(badString)) #expect(peerID != Optional(badString))
XCTAssertTrue(Optional(peerID) != badString) #expect(Optional(peerID) != badString)
XCTAssertTrue(Optional(peerID) != Optional(badString)) #expect(Optional(peerID) != Optional(badString))
// String == PeerID // String == PeerID
XCTAssertTrue(string == peerID) #expect(string == peerID)
XCTAssertTrue(Optional(string) == peerID) #expect(Optional(string) == peerID)
XCTAssertTrue(string == Optional(peerID)) #expect(string == Optional(peerID))
XCTAssertTrue(Optional(string) == Optional(peerID)) #expect(Optional(string) == Optional(peerID))
// String != PeerID // String != PeerID
XCTAssertTrue(badString != peerID) #expect(badString != peerID)
XCTAssertTrue(Optional(badString) != peerID) #expect(Optional(badString) != peerID)
XCTAssertTrue(badString != Optional(peerID)) #expect(badString != Optional(peerID))
XCTAssertTrue(Optional(badString) != Optional(peerID)) #expect(Optional(badString) != Optional(peerID))
// Regular PeerID <> PeerID
#expect(peerID == PeerID(str: "aaa"))
#expect(peerID == Optional(PeerID(str: "aaa")))
#expect(PeerID(str: "aaa") == peerID)
#expect(Optional(PeerID(str: "aaa")) == Optional(peerID))
// Make sure the regular PeerID <> PeerID is not broken #expect(peerID != PeerID(str: "bbb"))
XCTAssertTrue(peerID == PeerID(str: "aaa")) #expect(peerID != Optional(PeerID(str: "bbb")))
XCTAssertTrue(peerID == Optional(PeerID(str: "aaa"))) #expect(PeerID(str: "bbb") != peerID)
XCTAssertTrue(PeerID(str: "aaa") == peerID) #expect(Optional(PeerID(str: "bbb")) != Optional(peerID))
XCTAssertTrue(Optional(PeerID(str: "aaa")) == Optional(peerID))
XCTAssertTrue(peerID != PeerID(str: "bbb"))
XCTAssertTrue(peerID != Optional(PeerID(str: "bbb")))
XCTAssertTrue(PeerID(str: "bbb") != peerID)
XCTAssertTrue(Optional(PeerID(str: "bbb")) != Optional(peerID))
} }
// MARK: - Computed properties // MARK: - Computed properties
func test_isEmpty_true_and_false() { @Test func isEmpty_true_and_false() {
XCTAssertTrue(PeerID(str: "").isEmpty) #expect(PeerID(str: "").isEmpty)
XCTAssertFalse(PeerID(str: "abc").isEmpty) #expect(!PeerID(str: "abc").isEmpty)
} }
func test_isGeoChat() { @Test func isGeoChat() {
XCTAssertTrue(PeerID(str: "nostr:abcdef").isGeoChat) #expect(PeerID(str: "nostr:abcdef").isGeoChat)
XCTAssertFalse(PeerID(str: "nostr_abcdef").isGeoChat) // different prefix #expect(!PeerID(str: "nostr_abcdef").isGeoChat)
} }
func test_isGeoDM() { @Test func isGeoDM() {
XCTAssertTrue(PeerID(str: "nostr_abcdef").isGeoDM) #expect(PeerID(str: "nostr_abcdef").isGeoDM)
XCTAssertFalse(PeerID(str: "nostr:abcdef").isGeoDM) #expect(!PeerID(str: "nostr:abcdef").isGeoDM)
} }
func test_toPercentEncoded() { @Test func toPercentEncoded() {
let peerID = PeerID(str: "name:some value/with spaces?") let peerID = PeerID(str: "name:some value/with spaces?")
let encoded = peerID.toPercentEncoded() let encoded = peerID.toPercentEncoded()
// spaces and ? should be percent-encoded in urlPathAllowed // spaces and ? should be percent-encoded in urlPathAllowed
XCTAssertEqual(encoded, "name%3Asome%20value/with%20spaces%3F") #expect(encoded == "name%3Asome%20value/with%20spaces%3F")
} }
// MARK: - Validation // MARK: - Validation
func test_accepts_short_hex_peer_id() { @Test func accepts_short_hex_peer_id() {
XCTAssertTrue(PeerID(str: "0011223344556677").isValid) #expect(PeerID(str: "0011223344556677").isValid)
XCTAssertTrue(PeerID(str: "aabbccddeeff0011").isValid) #expect(PeerID(str: "aabbccddeeff0011").isValid)
} }
func test_accepts_full_noise_key_hex() { @Test func accepts_full_noise_key_hex() {
let hex64 = String(repeating: "ab", count: 32) // 64 hex chars let hex64 = String(repeating: "ab", count: 32) // 64 hex chars
XCTAssertTrue(PeerID(str: hex64).isValid) #expect(PeerID(str: hex64).isValid)
} }
func test_accepts_internal_alnum_dash_underscore() { @Test func accepts_internal_alnum_dash_underscore() {
XCTAssertTrue(PeerID(str: "peer_123-ABC").isValid) #expect(PeerID(str: "peer_123-ABC").isValid)
XCTAssertTrue(PeerID(str: "nostr_user_01").isValid) #expect(PeerID(str: "nostr_user_01").isValid)
} }
func test_rejects_invalid_characters() { @Test func rejects_invalid_characters() {
XCTAssertFalse(PeerID(str: "peer!@#").isValid) #expect(!PeerID(str: "peer!@#").isValid)
XCTAssertFalse(PeerID(str: "gggggggggggggggg").isValid) // not hex for short form #expect(!PeerID(str: "gggggggggggggggg").isValid) // not hex for short form
} }
func test_rejects_too_long() { @Test func rejects_too_long() {
let tooLong = String(repeating: "a", count: 65) let tooLong = String(repeating: "a", count: 65)
XCTAssertFalse(PeerID(str: tooLong).isValid) #expect(!PeerID(str: tooLong).isValid)
} }
func test_isShort() { @Test func isShort() {
XCTAssertTrue(PeerID(str: hex16).isShort) #expect(PeerID(str: hex16).isShort)
XCTAssertFalse(PeerID(str: "abcd").isShort) // wrong length #expect(!PeerID(str: "abcd").isShort) // wrong length
} }
func test_isNoiseKeyHex_and_noiseKey() { @Test func isNoiseKeyHex_and_noiseKey() {
let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex let hex64 = String(repeating: "ab", count: 32) // 64 chars valid hex
let peerID = PeerID(str: hex64) let peerID = PeerID(str: hex64)
XCTAssertTrue(peerID.isNoiseKeyHex) #expect(peerID.isNoiseKeyHex)
XCTAssertNotNil(peerID.noiseKey) #expect(peerID.noiseKey != nil)
let prefixedPeerID = PeerID(str: "noise:" + hex64) let prefixedPeerID = PeerID(str: "noise:" + hex64)
XCTAssertTrue(prefixedPeerID.isNoiseKeyHex) #expect(prefixedPeerID.isNoiseKeyHex)
XCTAssertNotNil(prefixedPeerID.noiseKey) #expect(prefixedPeerID.noiseKey != nil)
let bad = String(repeating: "z", count: 64) // invalid hex let bad = String(repeating: "z", count: 64) // invalid hex
let badPeerID = PeerID(str: bad) let badPeerID = PeerID(str: bad)
XCTAssertFalse(badPeerID.isNoiseKeyHex) #expect(!badPeerID.isNoiseKeyHex)
XCTAssertNil(badPeerID.noiseKey) #expect(badPeerID.noiseKey == nil)
} }
func test_prefixes() { @Test func prefixes() {
let hex64 = String(repeating: "a", count: 64) let hex64 = String(repeating: "a", count: 64)
XCTAssertTrue(PeerID(str: "noise:\(hex64)").isValid) #expect(PeerID(str: "noise:\(hex64)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex64)").isValid) #expect(PeerID(str: "nostr:\(hex64)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex64)").isValid) #expect(PeerID(str: "nostr_\(hex64)").isValid)
let hex63 = String(repeating: "a", count: 63) let hex63 = String(repeating: "a", count: 63)
XCTAssertTrue(PeerID(str: "noise:\(hex63)").isValid) #expect(PeerID(str: "noise:\(hex63)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex63)").isValid) #expect(PeerID(str: "nostr:\(hex63)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex63)").isValid) #expect(PeerID(str: "nostr_\(hex63)").isValid)
let hex16 = String(repeating: "a", count: 16) let hex16 = String(repeating: "a", count: 16)
XCTAssertTrue(PeerID(str: "noise:\(hex16)").isValid) #expect(PeerID(str: "noise:\(hex16)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex16)").isValid) #expect(PeerID(str: "nostr:\(hex16)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex16)").isValid) #expect(PeerID(str: "nostr_\(hex16)").isValid)
let hex8 = String(repeating: "a", count: 8) let hex8 = String(repeating: "a", count: 8)
XCTAssertTrue(PeerID(str: "noise:\(hex8)").isValid) #expect(PeerID(str: "noise:\(hex8)").isValid)
XCTAssertTrue(PeerID(str: "nostr:\(hex8)").isValid) #expect(PeerID(str: "nostr:\(hex8)").isValid)
XCTAssertTrue(PeerID(str: "nostr_\(hex8)").isValid) #expect(PeerID(str: "nostr_\(hex8)").isValid)
let mesh = "mesh:abcdefg" let mesh = "mesh:abcdefg"
XCTAssertTrue(PeerID(str: "name:\(mesh)").isValid) #expect(PeerID(str: "name:\(mesh)").isValid)
let name = "name:some_name" let name = "name:some_name"
XCTAssertTrue(PeerID(str: "name:\(name)").isValid) #expect(PeerID(str: "name:\(name)").isValid)
let badName = "name:bad:name" let badName = "name:bad:name"
XCTAssertFalse(PeerID(str: "name:\(badName)").isValid) #expect(!PeerID(str: "name:\(badName)").isValid)
// Too long // Too long
let hex65 = String(repeating: "a", count: 65) let hex65 = String(repeating: "a", count: 65)
XCTAssertFalse(PeerID(str: "noise:\(hex65)").isValid) #expect(!PeerID(str: "noise:\(hex65)").isValid)
XCTAssertFalse(PeerID(str: "nostr:\(hex65)").isValid) #expect(!PeerID(str: "nostr:\(hex65)").isValid)
XCTAssertFalse(PeerID(str: "nostr_\(hex65)").isValid) #expect(!PeerID(str: "nostr_\(hex65)").isValid)
} }
} }